@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-clientQuick 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
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:integrationTest Scenarios
The tests in test/scenarios/ demonstrate:
- Account Delegation - Creating delegated EIP-7702 accounts
- ERC20 Gasless Transfers - Transferring tokens without paying gas
- ETH Gasless Transfers - Sending ETH without paying gas, user-paid gas, and third-party sponsorship
- Nonce Management - Sequential and parallel intent execution
- 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 |
