Gasless Transactions

Primodium Gasless

Primodium Gasless is a server library for creating a gasless server with MUD-compliant (opens in a new tab) Ethereum smart contracts.

This library is available from npm with the package name @primodiumxyz/gasless-server (opens in a new tab). It is also available as a Docker container on the Github Container Registry (opens in a new tab) so you can run it straight away in a container.

Introduction

The source code for this monorepo is available on Github here (opens in a new tab). The monorepo contains the server library and a test contracts package for verifying the server's functionality.

Overview

This gasless server allows users to set up delegation within MUD systems for the paymaster/server wallet to make system calls on their behalf, without requiring them to pay gas.

Additionally, the server exposes endpoints to directly send signed transactions to the server, which will be broadcasted on the user's behalf. The main benefit here is that it allows native tokens to be passed from the user's wallet to some recipient or contract, which is not possible within the MUD system.

It provides types for both node and browser environments.

The smart contract toolkit in this repository is based on Foundry (opens in a new tab) and Anvil (opens in a new tab) as the local Ethereum development node.

Installation

Just install the package from npm, preferably with pnpm.

pnpm add @primodiumxyz/gasless-server

Quickstart

  1. Configuration

Add the following environment variables to your .env file:

VariableDescriptionDefault
GASLESS_SERVER_PRIVATE_KEYPrivate key to use for the server wallet0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
GASLESS_SERVER_CHAINChain to use (any viem chain)foundry
GASLESS_SERVER_PORTPort to run the server on3000
GASLESS_SERVER_SESSION_SECRETFastify session secretpqu3QS3OUB9tIiWntAEI7PkaIfp2H73Me2Lqq340FXc2
  1. Run
local-gasless-server
# or specify the path to your .env file (install @dotenvx/dotenvx first)
dotenvx run -f ./path/to/.env --quiet -- local-gasless-server

Usage

Docker

Usage with Docker is the recommended way to run the server, as you can directly consume the image published on the GitHub Container Registry (opens in a new tab).

You can use the server.docker-compose.yaml file provided for reference, fill in the environment variables, and run:

docker compose up

This will pull the image from the registry and start the server.

To stop the server, you can use:

docker compose down --remove-orphans

TypeScript

The tests provide a good overview of how to register/unregister delegations and then make calls with the server, or how to directly send signed transactions.

For instance, you can register a delegation with:

// Import from @primodiumxyz/gasless-server/react if you're using React
import {
  SERVER_WALLET,
  TIMEBOUND_DELEGATION,
  UNLIMITED_DELEGATION,
  type BadResponse,
  type RouteResponse,
} from "@primodiumxyz/gasless-server";
 
// Create the calldata for registering a delegation
const delegateCallData = encodeFunctionData({
  abi: WorldAbi,
  functionName: "registerDelegation",
  args: [
    SERVER_WALLET.account.address, // the paymaster wallet instance created from env.GASLESS_SERVER_PRIVATE_KEY we want to delegate to
    sessionLength ? TIMEBOUND_DELEGATION : UNLIMITED_DELEGATION, // the type of delegation we want to set
    sessionLength
      ? encodeFunctionData({
          abi: Abi,
          functionName: "initDelegation",
          args: [
            SERVER_WALLET.account.address,
            BigInt(Math.floor(Date.now() / 1000) + sessionLength),
          ], // delegate for some provided `sessionLength` seconds
        })
      : "0x", // if we're setting an unlimited delegation, we don't need to provide any init call data
  ],
});
 
// Sign the call data somehow (see __tests__/lib/sign.ts for an example)
const signature = await signCall({
  userClient: user,
  worldAddress: worldAddress,
  systemId: getSystemId("Registration"),
  callData: delegateCallData,
  nonce: await fetchSignatureNonce(userAddress), // see __tests__/lib/fetch.ts for an example
});
 
// Send the request to the server
const response = await fetch(`${serverUrl}/session`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    address: userAddress,
    worldAddress: worldAddress,
    params: [getSystemId("Registration"), delegateCallData, signature],
  }),
  credentials: "include",
});
 
// Handle the response
// You can create an agent to automatically map the response to the correct type depending on the request
// See __tests__/lib/agent.ts for an example
const data = (await response.json()) as
  | RouteResponse<"/session", "POST">
  | BadResponse;
console.log(data);
// -> { authenticated: true, txHash: '0x...' }

For more examples, see the tests directly; submitting a call after delegating, sending a signed transaction with or without native tokens.

Development

Running the server

pnpm dev:server # from root
pnpm dev # from packages/server, with watch mode
pnpm start # from packages/server, with production mode (no watch)

The server will start on the port specified in the .env file.

Testing and building

To run tests, first deploy the test contracts from the root directory:

# Add the Anvil private key to `packages/test-contracts/.env`
echo "PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" > packages/test-contracts/.env
 
# Run the Anvil development node and deploy contracts
pnpm run dev

Then run tests in a separate terminal session:

pnpm test
# or
pnpm test:watch
# or
pnpm test:ui

To build the server package, run:

pnpm build

Additional context

Limitations

There are some limitations to this server due to the intrinsic design of MUD. The specific issue is that a wallet cannot authorize a transaction with a native token transfer to be made on its behalf.

Details

The way MUD works is that the EOA of the delegator (the user) authorizes a delegate's EOA (the centralized wallet in the gasless server) to make system calls on its behalf—as in calls within the MUD system—but there is no way for an EOA to authorize another EOA to perform unlimited native token transfers on its behalf. Therefore, appending a native token transfer to a MUD call through delegation is technically possible, but it would transfer the funds out of the delegate's EOA, which is not what we want.

This design would however work for any app that doesn't require native token transfers; i.e., a game that solely performs calls within the MUD system, without any payment of native tokens.

Mitigation

One mitigation is to have the delegator sign the transaction, and send it to the server in order for the delegate to broadcast it, and effectively pay for the gas. This way, we're not using the MUD delegation system but rather some native EVM gas sponsorship. However, the user/delegator has to sign every single call, so even though this solution does enable gas sponsorship, it doesn't solve the UX problem of removing systematic interaction with the wallet for every single transaction to be made.

The above design is integrated into the gasless server under the signedCall route, and doesn't require initializing a MUD delegation (as it doesn't use it at all).

Potential solutions

There are a few solutions that could be implemented to enable true gasless and signless transactions. For instance:

  1. Using an ERC20 token (e.g. WETH) instead of native tokens, and pre-approving the delegate to transfer a large enough amount of that token on behalf of the delegator.
  2. Prompting the delegator to deposit native tokens into the delegate's wallet through a contract, and tracking their balance (deposited amount - amount spent on their behalf) on every call. This would revert if the user doesn't have enough "allowance" (balance). This would use the standard MUD delegation design, which is that native tokens are transferred from the delegate wallet, while we take care of depositing/tracking balances ourselves.
  3. Creating a smart account for the user, which would be able to delegate transfers of native tokens to another wallet as part of its design. This would require the user to deposit some funds into the smart account, so it's a bit similar to the previous solution in terms of UX.

Contributing

If you wish to contribute to the package, please open an issue first to make sure that this is within the scope of the library, and that it is not already being worked on.

License

This project is licensed under the MIT License - see LICENSE for details.