npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@towns-labs/relayer-client

v2.1.1

Published

A slim, viem-style SDK for interacting with the EIP-7702 Relayer Orchestrator system. This SDK enables gasless transactions through account delegation and intent-based execution.

Readme

@towns-labs/relayer-client

A slim, viem-style SDK for interacting with the EIP-7702 Relayer Orchestrator system. This SDK enables gasless transactions through account delegation and intent-based execution.

Development Note (Monorepo)

In this monorepo, @towns-labs/relayer-client imports shared RPC schema types from @towns-labs/relayer/rpc/schema/* to stay aligned with relayer endpoint contracts.

When changing relayer request/response shapes, update the relayer schema files first and then verify relayer-client build/tests.

Installation

bun add @towns-labs/relayer-client

Quick Start

import { createPublicClient, http, encodeFunctionData, erc20Abi } from "viem";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { relayerActions } from "@towns-labs/relayer-client";

// 1. Create the relayer client (just needs relayerUrl!)
const client = createPublicClient({
  chain: base,
  transport: http("https://mainnet.base.org"),
}).extend(
  relayerActions({
    relayerUrl: "https://your-relayer.example.com",
  }),
);

// 2. Generate a new account
const privateKey = generatePrivateKey();
const account = privateKeyToAccount(privateKey);

// 3. Create the delegated account via the relayer (two-step flow handled internally)
const createResult = await client.createAccount({
  accountAddress: account.address,
  signerKey: privateKey,
  delegation: "0x...TownsAccountAddress", // Get from checkHealth() or getCapabilities()
});

// 4. Execute gasless transactions via intents
const signedIntent = await client.signIntent({
  accountAddress: account.address,
  signerKey: privateKey,
  calls: [
    {
      target: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
      value: 0n,
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: "transfer",
        args: ["0xrecipient...", 1000000n], // 1 USDC
      }),
    },
  ],
});

const result = await client.submitIntent({ intent: signedIntent.intent });
console.log("Bundle ID:", result.bundleId);

Gas Payment Models

The SDK supports three gas payment models:

1. Fully Sponsored (Gasless)

The relayer pays for gas. User pays nothing.

const signedIntent = await client.signIntent({
  accountAddress: account.address,
  signerKey: privateKey,
  calls: [...],
  // No payer specified = fully sponsored
});

await client.submitIntent({ intent: signedIntent.intent });

2. User Pays Gas (Non-Sponsored)

The user reimburses the relayer for gas costs.

import { zeroAddress, parseEther } from "viem";

const signedIntent = await client.signIntent({
  accountAddress: account.address,
  signerKey: privateKey,
  calls: [...],
  payer: account.address,        // User pays
  paymentToken: zeroAddress,     // Native ETH (or ERC20 address)
  paymentMaxAmount: parseEther("0.01"), // Max willing to pay
});

await client.submitIntent({ intent: signedIntent.intent });

3. Third-Party Sponsor

A separate account (sponsor) pays for the user's gas. The sponsor must sign a payment authorization.

import { zeroAddress, parseEther } from "viem";

// User signs intent specifying sponsor as payer
const signedIntent = await client.signIntent({
  accountAddress: userAccount.address,
  signerKey: userPrivateKey,
  calls: [...],
  payer: sponsorAccount.address,  // Sponsor pays
  paymentToken: zeroAddress,
  paymentMaxAmount: parseEther("0.01"),
});

// Sponsor signs payment authorization
const paymentSignature = await client.signPayment({
  signedIntent,
  sponsorKey: sponsorPrivateKey,
});

// Submit with both signatures
await client.submitIntent({
  intent: { ...signedIntent.intent, paymentSignature },
});

Delegated Signers (Bot/Agent Authorization)

A powerful pattern is authorizing a bot or agent to act on behalf of a user's account with limited permissions. This enables automated actions while maintaining security through spend limits and call restrictions.

Setup: User Authorizes Bot

import {
  encodeSecp256k1Key,
  computeKeyHash,
  ANY_TARGET,
  EMPTY_CALLDATA_SELECTOR,
} from "@towns-labs/relayer-client";
import { zeroAddress, parseEther } from "viem";

// Bot is a separate delegated account
await client.createAccount({
  accountAddress: botAccount.address,
  signerKey: botPrivateKey,
  delegation: townsAccountAddress,
});

// User creates account and authorizes bot with limited permissions
const botPublicKey = encodeSecp256k1Key(botAccount.address);

await client.createAccount({
  accountAddress: userAccount.address,
  signerKey: userPrivateKey,
  delegation: townsAccountAddress,
  authorizeKeys: [
    {
      expiry: "0",
      type: "secp256k1",
      role: "normal", // Limited permissions, not admin
      publicKey: botPublicKey,
      permissions: [
        {
          type: "spend",
          token: zeroAddress, // ETH
          limit: parseEther("0.1").toString(), // Max 0.1 ETH per day
          period: "day",
        },
        {
          type: "call",
          to: ANY_TARGET,
          selector: EMPTY_CALLDATA_SELECTOR, // ETH transfers only
        },
      ],
    },
  ],
});

Bot Signs on Behalf of User

Since the bot is a delegated account (has bytecode), use signIntentAsDelegate:

const botKeyHash = computeKeyHash("secp256k1", botPublicKey);

// Bot signs intent to transfer from user's account
const signedIntent = await client.signIntentAsDelegate({
  accountAddress: userAccount.address,
  signerAddress: botAccount.address,
  signerKey: botPrivateKey,
  keyHash: botKeyHash,
  calls: [
    {
      target: recipientAddress,
      value: parseEther("0.05"), // Within daily limit
      data: "0x",
    },
  ],
});

await client.submitIntent({ intent: signedIntent.intent });

API Reference

Client Creation

relayerActions(config)

Extends a viem PublicClient with relayer actions:

import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
import { relayerActions } from "@towns-labs/relayer-client";

const client = createPublicClient({
  chain: base,
  transport: http("https://mainnet.base.org"),
}).extend(
  relayerActions({
    relayerUrl: "https://your-relayer.example.com",
  }),
);

Client Actions

checkHealth()

Checks the relayer's health status and returns contract addresses.

const health = await client.checkHealth();
// {
//   status: 'ok',
//   chainId: 8453,
//   relayerAddress: '0x...',
//   contracts: { orchestrator, simulator, townsAccount, accountProxy, simpleFunder }
// }

getCapabilities()

Gets the relayer's capabilities.

const caps = await client.getCapabilities();
// { capabilities: { accountCreation, intentExecution, simulation, ... } }

createAccount(params)

Creates a new EIP-7702 delegated account. Uses a two-step JSON-RPC flow internally.

const result = await client.createAccount({
  accountAddress: Address, // EOA to delegate
  signerKey: Hex, // Private key for signing the authorization
  delegation: Address, // TownsAccount implementation address
  authorizeKeys?: AuthorizeKey[], // Optional keys to authorize during upgrade
});
// { success, accountAddress, bundleIds }

Authorizing Keys as SuperAdmin:

When creating an account, you can authorize additional keys with admin or normal roles:

import { encodeAbiParameters } from "viem";

const result = await client.createAccount({
  accountAddress: account.address,
  signerKey: privateKey,
  delegation: townsAccountAddress,
  authorizeKeys: [
    {
      expiry: "0", // 0 = never expires
      type: "secp256k1", // or 'external' for ISigner contracts
      role: "admin", // 'admin' = superadmin, 'normal' = limited permissions
      publicKey: encodeAbiParameters([{ type: "address" }], [signerAddress]),
      permissions: [], // Empty for admin, or specify CallPermission/SpendPermission
    },
  ],
});

Key Types:

| Type | Description | | ----------- | ------------------------------------------------------------------- | | secp256k1 | Standard Ethereum EOA keys (same curve as EOAs) | | external | Delegated to an external ISigner contract for custom verification |

Roles:

| Role | Description | | -------- | ----------------------------------------------------------------------- | | admin | SuperAdmin - can call authorize() and revoke() to manage other keys | | normal | Limited to specified permissions only |

signIntent(params)

Signs an intent for execution. Returns a SignedIntent containing the intent, digest, and typed data (for third-party sponsorship).

const signedIntent = await client.signIntent({
  accountAddress: Address, // The delegated account address
  signerKey: Hex, // Private key for signing
  calls: Call[], // Array of calls to execute
  // Optional:
  nonce?: bigint, // Override nonce
  seqKey?: bigint, // Sequence key for parallel execution (2D nonce)
  combinedGas?: bigint, // Override gas limit
  expiry?: bigint, // Override expiry timestamp
  // Payment options (see Gas Payment Models):
  payer?: Address, // Who pays for gas
  paymentToken?: Address, // zeroAddress for ETH, or ERC20 address
  paymentMaxAmount?: bigint, // Maximum willing to pay
});
// Returns: { intent, digest, typedData }

signIntentWithWallet(params)

Signs an intent using a viem WalletClient instead of a raw private key. Useful for browser wallets and hardware wallets.

import { createWalletClient, custom } from "viem";

const walletClient = createWalletClient({
  chain: base,
  transport: custom(window.ethereum),
});

const signedIntent = await client.signIntentWithWallet({
  accountAddress: Address,
  walletClient: WalletClient,
  calls: Call[],
});
// Returns: { intent, digest, typedData }

signIntentAsDelegate(params)

Signs an intent when the signer is itself a delegated account (smart contract). This handles the ERC-1271 replay-safe digest transformation required for smart contract signatures.

Use this when:

  • A bot account (delegated) signs on behalf of a user account
  • An agent with limited permissions acts on behalf of another delegated account
import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";

// Bot is a delegated account that was authorized on user's account
const botPublicKey = encodeSecp256k1Key(botAccount.address);
const botKeyHash = computeKeyHash("secp256k1", botPublicKey);

const signedIntent = await client.signIntentAsDelegate({
  accountAddress: userAccount.address, // Account to execute on
  signerAddress: botAccount.address, // Delegated signer's address
  signerKey: botPrivateKey, // Delegated signer's private key
  keyHash: botKeyHash, // Key hash in the target account
  calls: [
    {
      target: recipientAddress,
      value: parseEther("0.01"),
      data: "0x",
    },
  ],
});

await client.submitIntent({ intent: signedIntent.intent });

Why is this needed?

When a signer has bytecode (is a smart contract/delegated account), signature verification follows the ERC-1271 standard. The digest must be transformed to include the signer's address for replay protection. This method handles that transformation automatically.

signPayment(params)

Signs a payment authorization for third-party gas sponsorship. The sponsor calls this to authorize paying for someone else's transaction.

const paymentSignature = await client.signPayment({
  signedIntent: SignedIntent, // From signIntent()
  sponsorKey: Hex, // Sponsor's private key
});
// Returns: Hex (the payment signature)

signPaymentWithWallet(params)

Signs a payment authorization using a WalletClient instead of a raw private key.

const paymentSignature = await client.signPaymentWithWallet({
  signedIntent: SignedIntent,
  walletClient: WalletClient, // Sponsor's wallet client
});
// Returns: Hex (the payment signature)

submitIntent(params)

Submits a signed intent for execution.

const result = await client.submitIntent({ intent: signedIntent.intent });
// { success, bundleId }

submitBatch(params)

Submits multiple intents as a single batch for gas-efficient execution. The relayer optimizes homogeneous batches into a single on-chain transaction, while each intent gets its own bundleId for status tracking. All intents execute atomically (all succeed or all fail).

// Sign multiple intents (can be different accounts)
const signedIntents = await Promise.all([
  client.signIntent({ accountAddress: addr1, signerKey: key1, calls: [...] }),
  client.signIntent({ accountAddress: addr2, signerKey: key2, calls: [...] }),
]);

// Submit as batch - executes in single on-chain transaction
const result = await client.submitBatch({
  intents: signedIntents.map((s) => s.intent),
});
// { success, bundleIds, succeeded, failed }

// Poll status for each bundle
for (const bundleId of result.bundleIds) {
  const status = await client.getBundleStatus({ bundleId });
}

For parallel intents from the same account, use different seqKey values:

const signedIntents = await Promise.all([
  client.signIntent({ accountAddress, signerKey, seqKey: 0n, calls: [...] }),
  client.signIntent({ accountAddress, signerKey, seqKey: 1n, calls: [...] }),
]);
await client.submitBatch({ intents: signedIntents.map((s) => s.intent) });

prepareIntent(params)

Prepares an intent for signing (advanced use). Returns EIP-712 typed data for manual signing. Also provides gas estimation via combinedGas - use this instead of simulateIntent.

const prepared = await client.prepareIntent({
  accountAddress: Address,
  calls: Call[],
});
// { success, typedData, nonce, combinedGas, expiry, digest }

getBundleStatus(params)

Gets the status of a submitted bundle.

const status = await client.getBundleStatus({ bundleId });
// { status: 'pending' | 'confirmed' | 'failed' | 'reverted', statusCode, receipt }

Status Codes:

| Code | Status | Meaning | | ---- | ----------- | ---------------------------------------------------- | | 100 | pending | Still waiting for confirmation | | 200 | confirmed | Intent executed successfully | | 300 | failed | Transaction never submitted/mined (offchain failure) | | 400 | reverted | Transaction mined but intent failed on-chain | | 500 | reverted | Some intents in bundle failed (partial revert) |

waitForBundle(params)

Waits for a bundle to reach a final status (confirmed, failed, or reverted).

const status = await client.waitForBundle({
  bundleId: result.bundleId!,
  timeoutMs: 60_000, // Optional, default 30s
});

if (status.status === "confirmed") {
  console.log("Success!", status.receipt?.transactionHash);
} else if (status.status === "reverted") {
  console.log("Intent failed on-chain:", status.receipt?.intentError);
}

executeIntent(params)

Convenience method that combines signIntent, submitIntent, and waitForBundle into a single call.

// Simple execution with waiting (default)
const result = await client.executeIntent({
  accountAddress: account.address,
  signerKey: privateKey,
  calls: [
    {
      target: recipientAddress,
      value: parseEther("0.1"),
      data: "0x",
    },
  ],
});

if (result.success) {
  console.log("Transaction hash:", result.txHash);
} else {
  console.log("Failed:", result.error);
}

// Fire and forget (don't wait for confirmation)
const result = await client.executeIntent({
  accountAddress: account.address,
  signerKey: privateKey,
  calls: [...],
  waitForConfirmation: false,
});
console.log("Bundle submitted:", result.bundleId);

Utilities

encodeSecp256k1Key(address)

Encodes an address as a secp256k1 public key for TownsAccount. This is a convenience wrapper for the common pattern of ABI-encoding an address.

import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";

const publicKey = encodeSecp256k1Key(signerAddress);
const keyHash = computeKeyHash("secp256k1", publicKey);

computeKeyHash(keyType, publicKey)

Computes the key hash for an authorized key. This matches TownsAccount's key hash computation.

import { encodeSecp256k1Key, computeKeyHash } from "@towns-labs/relayer-client";

// For secp256k1 keys
const publicKey = encodeSecp256k1Key(signerAddress);
const keyHash = computeKeyHash("secp256k1", publicKey);

// For external signer contracts
const externalPublicKey = concat([signerContract, `0x${"00".repeat(12)}`]);
const externalKeyHash = computeKeyHash("external", externalPublicKey);

wrapSignature(signature, keyHash, prehash?)

Wraps a signature with keyHash and prehash flag for TownsAccount validation. This is the format expected by TownsAccount.unwrapAndValidateSignature.

import { wrapSignature } from "@towns-labs/relayer-client";

// Wrap a raw signature with key hash
const wrappedSignature = wrapSignature(rawSignature, keyHash);

// With prehash flag (for certain signing scenarios)
const wrappedSignature = wrapSignature(rawSignature, keyHash, true);

computeErc1271Digest(digest, accountAddress)

Computes the ERC-1271 replay-safe digest for smart contract signatures. Use this when manually signing as a delegated account.

import { computeErc1271Digest } from "@towns-labs/relayer-client";

// Transform digest for ERC-1271 signing
const erc1271Digest = computeErc1271Digest(originalDigest, signerAddress);

// Sign the transformed digest
const signature = await sign({ hash: erc1271Digest, privateKey });

decodeIntentError(selector)

Decodes an intent error selector (bytes4) to a human-readable name.

import { decodeIntentError } from "@towns-labs/relayer-client";

const status = await client.getBundleStatus({ bundleId });
if (status.receipt?.intentError) {
  const errorName = decodeIntentError(status.receipt.intentError);
  console.log("Intent failed:", errorName); // e.g., 'ExceededSpendLimit'
}

Permission Constants

Constants for configuring session key permissions:

import {
  ANY_TARGET,
  EMPTY_CALLDATA_SELECTOR,
  ERC20_SELECTORS,
} from "@towns-labs/relayer-client";

// Allow ETH transfers to any address
const ethTransferPermission: CallPermission = {
  type: "call",
  to: ANY_TARGET,
  selector: EMPTY_CALLDATA_SELECTOR,
};

// Allow ERC20 transfers to any address
const erc20TransferPermission: CallPermission = {
  type: "call",
  to: ANY_TARGET,
  selector: ERC20_SELECTORS.TRANSFER,
};

| Constant | Value | Description | | ------------------------------- | --------------- | ------------------------------------------------- | | ANY_TARGET | 0x3232...3232 | Matches any contract address | | EMPTY_CALLDATA_SELECTOR | 0xe0e0e0e0 | Matches calls with empty calldata (ETH transfers) | | ERC20_SELECTORS.TRANSFER | 0xa9059cbb | ERC20 transfer(address,uint256) | | ERC20_SELECTORS.APPROVE | 0x095ea7b3 | ERC20 approve(address,uint256) | | ERC20_SELECTORS.TRANSFER_FROM | 0x23b872dd | ERC20 transferFrom(address,address,uint256) |

Types

interface Call {
  target: Address; // Contract to call
  value: bigint; // ETH value to send
  data: Hex; // Calldata
}

interface Intent {
  eoa: Address;
  calls: Call[];
  nonce: bigint;
  combinedGas: bigint;
  expiry: bigint;
  signature: Hex;
  // Payment fields (optional)
  payer?: Address;
  paymentToken?: Address;
  paymentMaxAmount?: bigint;
  paymentAmount?: bigint;
  paymentSignature?: Hex;
}

interface SignedIntent {
  intent: Intent;
  digest: Hex; // EIP-712 digest (for third-party sponsorship)
  typedData: {
    domain: EIP712Domain;
    types: typeof INTENT_TYPES;
    primaryType: "Intent";
    message: Record<string, unknown>;
  };
}

interface SubmitBatchParams {
  intents: Intent[];
}

interface BatchIntentsResponse {
  success: boolean;
  bundleIds?: string[]; // One per intent for status tracking
  succeeded?: number;
  failed?: number;
  error?: string;
}

// Key authorization types
type KeyType = "secp256k1" | "external";

interface AuthorizeKey {
  expiry: string; // Unix timestamp, "0" = never expires
  type: KeyType;
  role: "admin" | "normal"; // admin = superadmin
  publicKey: Hex; // For secp256k1: encodeAbiParameters([{type:'address'}], [addr])
  permissions: Permission[];
}

interface CallPermission {
  type: "call";
  to: Address;
  selector: Hex; // 4-byte function selector
}

interface SpendPermission {
  type: "spend";
  token: Address;
  limit: string;
  period: "minute" | "hour" | "day" | "week" | "month" | "year";
}

type Permission = CallPermission | SpendPermission;

Running Tests

The integration tests serve as comprehensive examples of SDK usage.

Prerequisites

  • Foundry (for Anvil)
  • Bun
  • A Base mainnet RPC URL (e.g., from Alchemy)

Run Tests

From the packages/relayer-client directory:

# Start services and run tests (recommended)
FORK_RPC_URL=https://your-rpc-url ./scripts/local-dev.sh --test

# Or start services in dev mode, then run tests separately
FORK_RPC_URL=https://your-rpc-url ./scripts/local-dev.sh
# In another terminal:
bun run test:integration

Test Scenarios

The tests in test/scenarios/ demonstrate:

  1. Account Delegation - Creating delegated EIP-7702 accounts
  2. ERC20 Gasless Transfers - Transferring tokens without paying gas
  3. ETH Gasless Transfers - Sending ETH without paying gas, user-paid gas, and third-party sponsorship
  4. Nonce Management - Sequential and parallel intent execution
  5. Batch Execution - Submitting multiple intents in a single transaction

Supported Chains

| Chain | Chain ID | Status | | ------------ | -------- | --------- | | Base Mainnet | 8453 | Supported | | Base Sepolia | 84532 | Supported | | Local Anvil | 31337 | Supported |