Skip to main content
EVM Gas Sponsorship is an enterprise-only feature. Contact us to learn more about upgrading your plan.
Normally, a user needs to hold some of a network’s native token (like ETH) to pay the “gas” fee on every transaction. Gas sponsorship lets your app pay those fees instead, so users can transact without ever topping up a wallet. On the server, the wallet client signs a sponsored transaction with the user’s embedded-wallet shares, relays the signed intent to Dynamic’s sponsorship backend, and waits for it to land on-chain. For the basic case you don’t need to understand relayers or delegation. Enable it in the dashboard and call one method.
EVM Gas Sponsorship works only with V3 MPC embedded wallets (the wallets Dynamic creates for your users). It does not work with externally imported keys that you sign for directly.

Quick start

This is everything you need to sponsor a transaction. The SDK handles the underlying setup for you automatically.
1

Turn on gas sponsorship in the dashboard

  1. Go to the Dynamic Dashboard
  2. Navigate to Settings > Embedded Wallets
  3. Make sure the EVM chains you want to sponsor are enabled
  4. Toggle on EVM Gas Sponsorship
2

Send a sponsored transaction

Call sendSponsoredTransaction with the wallet’s key shares, the chainId, an rpcUrl, and a list of calls (what you want the transaction to do). It signs the intent, relays it, waits for the transaction to land on-chain, and returns the transaction hash.
import xyz.dynamic.waas.evm.SponsoredCall;
import xyz.dynamic.waas.evm.SignedSponsoredTransaction;
import xyz.dynamic.waas.evm.opts.SignSponsoredTransactionOpts;

import java.util.List;

// Load the WalletProperties + shares you persisted at creation time.
// See /java/storage-best-practices for the recommended split.

SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)  // Base
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(List.of(new SponsoredCall(
            recipientAddress,  // who/what you're sending to
            "0",               // wei amount of native token to send
            "0x"               // '0x' = a plain token transfer
        )))
        .build()
).join();

String txHash = client.sendSponsoredTransaction(signed).join();
System.out.println("Sponsored transaction confirmed: " + txHash);
The user pays no gas, and you didn’t have to think about delegation or relayers.
The first time a wallet sends a sponsored transaction, the SDK does a one-time on-chain setup (EIP-7702 delegation) for you automatically. For that to work it needs an rpcUrl so it can read the wallet’s delegation state and EOA nonce. If you omit rpcUrl, either pass autoDelegate(false) or supply a pre-signed authorization (see Managing EIP-7702 delegation yourself).

What goes in calls

Each entry in the calls list describes one action the transaction should perform. Most apps only need a single call.
FieldTypeDescription
targetString0x-prefixed address you’re sending to (a recipient or a contract).
valueStringAmount of native token in wei as a decimal string. Use "0" when no native token should move.
dataString0x-prefixed hex calldata. Use "0x" for a plain native-token transfer.

Sending an ERC-20 token (USDC)

To move an ERC-20 token instead of native gas, point target at the token contract, encode a transfer call as data, and set value to "0":
// USDC on Base (6 decimals)
String USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";

// ERC-20 transfer(address,uint256) selector = 0xa9059cbb
// Encode: 32-byte padded address + 32-byte padded amount (10 USDC = 10_000_000)
String transferData = "0xa9059cbb"
    + String.format("%064x", new java.math.BigInteger(recipientAddress.substring(2), 16))
    + String.format("%064x", java.math.BigInteger.valueOf(10_000_000));

SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(List.of(new SponsoredCall(USDC_BASE, "0", transferData)))
        .build()
).join();

String txHash = client.sendSponsoredTransaction(signed).join();

Batching multiple calls

A single sponsored transaction can carry several calls, executed in order within one on-chain transaction. This is useful for paying multiple recipients at once:
List<SponsoredCall> payouts = List.of(
    new SponsoredCall("0x1111111111111111111111111111111111111111", "0", transferData1),
    new SponsoredCall("0x2222222222222222222222222222222222222222", "0", transferData2)
);

SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(payouts)
        .build()
).join();

String txHash = client.sendSponsoredTransaction(signed).join();

Relaying on behalf of an end user

By default the transaction is relayed as the authenticated service user. To relay on behalf of one of your end users (the wallet’s owner), pass their userId:
String txHash = client.sendSponsoredTransaction(signed, endUserId,
    EvmGaslessConstants.EVM_GASLESS_DEFAULT_POLL_INTERVAL_MS,
    EvmGaslessConstants.EVM_GASLESS_DEFAULT_TIMEOUT_MS
).join();
userId works the same way on relaySponsoredTransaction.

Advanced usage

Everything below is optional. Reach for it only when you need more control than the quick start gives you: pre-signing, custom polling, or managing the one-time delegation step yourself.

How it works under the hood

When you call signSponsoredTransaction followed by sendSponsoredTransaction, the SDK:
  1. Builds an EIP-712 intent describing the batch of calls and a deadline
  2. Signs the intent with the user’s embedded-wallet shares (and, if delegation is needed, an EIP-7702 authorization for the wallet’s EOA)
  3. Relays the signed intent to Dynamic’s sponsorship backend and gets back a requestId
  4. Polls the relayer until the request reaches a terminal state, then returns the on-chain transaction hash
EIP-7702 delegation is what lets a relayer submit a batch of calls on the user’s behalf. The user’s EOA is delegated, once, to a Dynamic-operated relayer contract (0x0000Fb7702036ff9f76044a501ac1aA74cbab16b).

Supported chains

Dynamic operates relayers on the following EVM chains. Mainnet
ChainChain ID
Ethereum Mainnet1
Base8453
Optimism10
Arbitrum One42161
BNB Smart Chain56
Testnet
ChainChain ID
Ethereum Sepolia11155111
Base Sepolia84532

Splitting sign and relay

Reuse a pre-signed intent (for example, to sign in one process and relay from another) by calling signSponsoredTransaction first and passing the result to sendSponsoredTransaction:
SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(calls)
        .build()
).join();

// The signed intent is JSON-serializable — persist or transfer it here.

String txHash = client.sendSponsoredTransaction(signed).join();
SignedSponsoredTransaction is a Java record containing walletAddress, calls, chainId, deadline, nonce, relayer, signature, and an optional authorization. It can be serialized to JSON for cross-process transport. By default the signed intent is valid for 10 minutes. Override with validForSeconds:
SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(calls)
        .validForSeconds(60)
        .build()
).join();

Reusing a nonce

By default each signed intent gets a fresh single-use bitmap nonce. Pass nonce (a BigInteger) to reuse one across intents: sign several intents with the same nonce so at most one can ever land on-chain (cancel-replace):
import java.math.BigInteger;

SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .calls(calls)
        .nonce(BigInteger.valueOf(42))
        .build()
).join();

Managing EIP-7702 delegation yourself

Before a wallet can submit a sponsored transaction, its EOA must be delegated to the Dynamic relayer contract via an EIP-7702 authorization. The quick start handles this automatically on the first transaction (when you provide an rpcUrl). These helpers exist for when you want explicit control over the delegation step.

Checking delegation status

Use is7702DelegationActive to check whether delegation is already active for a wallet on a given chain. It reads the wallet’s on-chain code, so it requires an rpcUrl:
boolean isActive = client.is7702DelegationActive(
    walletProperties.accountAddress(),
    8453L,
    System.getenv("BASE_RPC_URL")
).join();

Signing an authorization

sign7702Authorization signs an EIP-7702 authorization for the Dynamic delegation contract. Pass the result as authorization to signSponsoredTransaction to attach it to the next sponsored call. This is useful when you want to skip auto-delegation (e.g. you’ve set autoDelegate(false), or you’re signing without an rpcUrl):
import xyz.dynamic.waas.evm.Eip7702Authorization;
import xyz.dynamic.waas.evm.opts.Sign7702AuthorizationOpts;

Eip7702Authorization authorization = client.sign7702Authorization(
    Sign7702AuthorizationOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .rpcUrl(System.getenv("BASE_RPC_URL"))
        .build()
).join();

SignedSponsoredTransaction signed = client.signSponsoredTransaction(
    SignSponsoredTransactionOpts.builder()
        .walletProperties(walletProperties)
        .externalServerKeyShares(externalServerKeyShares)
        .chainId(8453L)
        .calls(calls)
        .authorization(authorization)
        .autoDelegate(false)
        .build()
).join();

String txHash = client.sendSponsoredTransaction(signed).join();
When nonce is omitted, sign7702Authorization fetches the EOA nonce from the chain via rpcUrl; pass an explicit nonce to sign without an RPC.

Polling the relay status yourself

For custom progress reporting, call relaySponsoredTransaction (which returns a requestId immediately) and poll getSponsoredTransactionStatus yourself:
import xyz.dynamic.waas.evm.SponsorTransactionResult;
import xyz.dynamic.waas.evm.SponsoredTransactionStatusResult;

SponsorTransactionResult relay = client.relaySponsoredTransaction(signed).join();
String requestId = relay.requestId();

SponsoredTransactionStatusResult status =
    client.getSponsoredTransactionStatus(requestId).join();
status is one of:
StatusMeaning
pendingAccepted by the relayer, not yet broadcast to the chain.
submittedBroadcast to the chain, waiting for confirmation.
successFinalized successfully on-chain.
failureTerminal failure (errorMessage is populated).
transactionHash is set once the relay broadcasts the transaction. For the common “wait until done” case, use waitForSponsoredTransaction, which polls every 2 seconds and resolves on success (timeout: 60 seconds):
import xyz.dynamic.waas.evm.opts.WaitForSponsoredTransactionOpts;
import java.time.Duration;

String txHash = client.waitForSponsoredTransaction(
    requestId,
    WaitForSponsoredTransactionOpts.builder().build()
).join();
Override the cadence with pollInterval and timeout:
String txHash = client.waitForSponsoredTransaction(
    requestId,
    WaitForSponsoredTransactionOpts.builder()
        .pollInterval(Duration.ofSeconds(1))
        .timeout(Duration.ofSeconds(120))
        .build()
).join();

Error handling

These methods throw a standard CompletionException when sponsorship can’t go through. There is no silent fallback. Wrap the call in a try/catch so you can surface a message and decide what to do next.
import java.util.concurrent.CompletionException;

try {
    String txHash = client.sendSponsoredTransaction(signed).join();
    System.out.println("Confirmed: " + txHash);
} catch (CompletionException ce) {
    // e.g. "EVM sponsored transaction relay failed" or
    //      "EVM sponsored transaction timed out waiting for terminal status"
    System.err.println("Sponsorship failed: " + ce.getCause().getMessage());
}
An error is thrown when:
  • The sponsorship API rejects the request (sponsorship not enabled, chain not supported, or no relayer available)
  • The relay reaches a terminal failure status
  • waitForSponsoredTransaction times out (default 60 seconds) before reaching a terminal status
  • autoDelegate is on but no rpcUrl (and no pre-signed authorization) was supplied

Limitations

LimitationDetails
Wallet typeEmbedded wallets only (V3 MPC)
MechanismEIP-7702 delegation to the Dynamic relayer contract
Intent validity10 minutes by default; configurable via validForSeconds
Polling timeoutwaitForSponsoredTransaction resolves or throws within 60 seconds by default
BatchingOne signed intent per sendSponsoredTransaction call; each intent can contain multiple calls
Last modified on June 30, 2026