Plugins
Development Documentation
Overview
OpenZeppelin Relayer supports plugins to extend the functionality of the relayer.
Plugins are TypeScript functions running in the Relayer server that can include any arbitrary logic defined by the Relayer operator.
The plugin system features:
- Handler Pattern: Simple export-based plugin development
 - TypeScript Support: Full type safety and IntelliSense
 - Plugin API: Clean interface for interacting with relayers
 - Key-Value Storage: Persistent state and locking for plugins
 - Docker Integration: Seamless development and deployment
 - Comprehensive Error Handling: Detailed logging and debugging capabilities
 
Configuration
Writing a Plugin
Plugins are declared under plugins directory, and are expected to be TypeScript files (.ts extension).
openzeppelin-relayer/
├── plugins/
│   └── my-plugin.ts    # Plugin code
└── config/
    └── config.json     # Plugins in configuration fileHandler Pattern (Recommended)
This approach uses a simple handler export pattern with a single context parameter:
/// Required imports.
import { Speed, PluginContext, pluginError } from "@openzeppelin/relayer-sdk";
/// Define your plugin parameters interface
type MyPluginParams = {
  destinationAddress: string;
  amount?: number;
  message?: string;
  relayerId?: string;
}
/// Define your plugin return type
type MyPluginResult = {
  transactionId: string;
  confirmed: boolean;
  note?: string;
}
/// Export a handler function - that's it!
export async function handler(context: PluginContext): Promise<MyPluginResult> {
    const { api, params, kv } = context;
    console.info("🚀 Plugin started...");
    // Validate parameters
    if (!params.destinationAddress) {
        throw pluginError("destinationAddress is required", { code: 'MISSING_PARAM', status: 400, details: { field: 'destinationAddress' } });
    }
    // Use the relayer API
    const relayer = api.useRelayer(params.relayerId || "my-relayer");
    const result = await relayer.sendTransaction({
        to: params.destinationAddress,
        value: params.amount || 1,
        data: "0x",
        gas_limit: 21000,
        speed: Speed.FAST,
    });
    console.info(`Transaction submitted: ${result.id}`);
    // Optionally store something in KV
    await kv.set("last_tx_id", result.id);
    // Wait for confirmation
    await result.wait({
        interval: 5000,  // Check every 5 seconds
        timeout: 120000  // Timeout after 2 minutes
    });
    return {
        transactionId: result.id,
        message: `Successfully sent ${params.amount || 1} wei to ${params.destinationAddress}`
    };
}Legacy Patterns (Deprecated, but supported)
The legacy patterns below are deprecated and will be removed in a future version. Please migrate to the single-context handler pattern. Legacy plugins continue to work but will show deprecation warnings. The two-parameter handler does not have access to the KV store.
// Legacy: runPlugin pattern (deprecated)
import { runPlugin, PluginAPI } from "../lib/plugin";
async function myPlugin(api: PluginAPI, params: any) {
  // Plugin logic here (no KV access)
  return "result";
}
runPlugin(myPlugin);Legacy handler (two-parameter, deprecated, no KV):
import { PluginAPI } from "@openzeppelin/relayer-sdk";
export async function handler(api: PluginAPI, params: any): Promise<any> {
  // Same logic as before, but no KV access in this form
  return "done!";
}Declaring in config file
Plugins are configured in the ./config/config.json file, under the plugins key.
The file contains a list of plugins, each with an id, path and timeout in seconds (optional).
The plugin path is relative to the /plugins directory
Example:
"plugins": [
  {
    "id": "my-plugin",
    "path": "my-plugin.ts",
    "timeout": 30
  }
]Timeout
The timeout is the maximum time in seconds that the plugin can run. If the plugin exceeds the timeout, it will be terminated with an error.
The timeout is optional, and if not provided, the default is 300 seconds (5 minutes).
Plugin Development Guidelines
TypeScript Best Practices
- Define Parameter Types: Always create interfaces or types for your plugin parameters
 - Define Return Types: Specify what your plugin returns for better developer experience
 - Handle Errors Gracefully: Use try-catch blocks and return structured error responses
 - Validate Input: Check required parameters and provide meaningful error messages
 - Use Async/Await: Modern async patterns for better readability
 
Testing Your Plugin
You can test your handler function directly with a mocked context:
import { handler } from './my-plugin';
import type { PluginContext } from '@openzeppelin/relayer-sdk';
const mockContext = {
  api: {
    useRelayer: (_id: string) => ({
      sendTransaction: async () => ({ id: 'test-tx-123', wait: async () => ({ hash: '0xhash' }) })
    })
  },
  params: {
    destinationAddress: '0x742d35Cc6640C21a1c7656d2c9C8F6bF5e7c3F8A',
    amount: 1000
  },
  kv: {
    set: async () => true,
    get: async () => null,
    del: async () => true,
    exists: async () => false,
    scan: async () => [],
    clear: async () => 0,
    withLock: async (_k: string, fn: () => Promise<any>) => fn(),
    connect: async () => {},
    disconnect: async () => {}
  }
} as unknown as PluginContext;
const result = await handler(mockContext);
console.log(result);Invocation
Plugins are invoked by hitting the api/v1/plugins/plugin-id/call endpoint.
The endpoint accepts a POST request. Example post request body:
{
  "params": {
    "destinationAddress": "0x742d35Cc6640C21a1c7656d2c9C8F6bF5e7c3F8A",
    "amount": 1000000000000000,
    "message": "Hello from OpenZeppelin Relayer!"
  }
}The parameters are passed directly to your plugin’s handler function.
Responses
API responses use the ApiResponse envelope: success, data, error, metadata.
Success responses (HTTP 200)
datacontains your handler return value (decoded from JSON when possible).metadata.logs?andmetadata.traces?are only populated if the plugin configuration enablesemit_logs/emit_traces.errorisnull.
Plugin errors (HTTP 4xx)
- Throwing 
pluginError(...)(or anyError) is normalized into a consistent HTTP payload. errorprovides the client-facing message, derived from the thrown error or from log output when the message is empty.datacarriescode?: string, details?: anyreported by the plugin.metadatafollows the same visibility rules (emit_logs/emit_traces).
Complete Example
- Plugin Code (
plugins/example.ts): 
import { Speed, PluginContext, pluginError } from "@openzeppelin/relayer-sdk";
type ExampleResult = {
  transactionId: string;
  transactionHash: string | null;
  message: string;
  timestamp: string;
}
export async function handler(context: PluginContext): Promise<ExampleResult> {
  const { api, params, kv } = context;
  console.info("🚀 Example plugin started");
  console.info(`📋 Parameters:`, JSON.stringify(params, null, 2));
  if (!params.destinationAddress) {
    throw pluginError("destinationAddress is required", { code: 'MISSING_PARAM', status: 400, details: { field: 'destinationAddress' } });
  }
    const amount = params.amount || 1;
    const message = params.message || "Hello from OpenZeppelin Relayer!";
    console.info(`💰 Sending ${amount} wei to ${params.destinationAddress}`);
    const relayer = api.useRelayer("my-relayer");
    const result = await relayer.sendTransaction({
      to: params.destinationAddress,
      value: amount,
      data: "0x",
      gas_limit: 21000,
      speed: Speed.FAST,
    });
    // Example persistence
    await kv.set('last_transaction', result.id);
    const confirmation = await result.wait({ interval: 5000, timeout: 120000 });
  return {
    transactionId: result.id,
    transactionHash: confirmation.hash || null,
    message: `Successfully sent ${amount} wei to ${params.destinationAddress}. ${message}`,
    timestamp: new Date().toISOString(),
  };
}- Plugin Configuration (
config/config.json): 
{
  "plugins": [
    {
      "id": "example-plugin",
      "path": "example-plugin.ts",
      "timeout": 30
    }
  ]
}- API Invocation:
 
curl -X POST http://localhost:8080/api/v1/plugins/example-plugin/call \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
  "params": {
    "destinationAddress": "0x742d35Cc6640C21a1c7656d2c9C8F6bF5e7c3F8A",
    "amount": 1000000000000000,
    "message": "Test transaction from plugin"
  }
}'- API Response (Success):
 
{
  "success": true,
  "data": {
    "transactionId": "tx-123456",
    "confirmed": true,
    "note": "Sent 1000000000000000 wei to 0x742d35Cc..."
  },
  "metadata": {
    "logs": [ { "level": "info", "message": "🚀 Example plugin started" } ],
    "traces": [ { "relayerId": "my-relayer", "method": "sendTransaction", "payload": { /* ... */ } } ]
  },
  "error": null
}- API Response (Error):
 
{
  "success": false,
  "data":
  {
    "code": "MISSING_PARAM",
    "details": { "field": "destinationAddress" }
  },
  "metadata": {
    "logs": [ { "level": "error", "message": "destinationAddress is required" } ]
  },
  "error": "destinationAddress is required"
}== Response Fields
data: The value returned by your plugin's handler function (decoded from JSON when possible)metadata.logs: Terminal output from the plugin (console.log, console.error, etc.) whenemit_logsis truemetadata.traces: Messages exchanged between the plugin and the Relayer via PluginAPI whenemit_tracesis trueerror: Error message if the plugin execution failed (business errors)
== Key-Value Storage
The Relayer provides a built-in key-value store for plugins to maintain persistent state across invocations. This addresses the core problem of enabling persistent state management and programmatic configuration updates for plugins.
=== Why a KV store?
- Plugins execute as isolated processes with no persistent memory
 - No mechanism exists to maintain state between invocations
 - Plugins requiring shared state or coordination need safe concurrency primitives
 
=== Configuration
- Reuses the same Redis URL as the Relayer via the 
REDIS_URLenvironment variable - No extra configuration is required
 - Keys are namespaced per plugin ID to prevent collisions
 
=== Usage
Access the KV store through the kv property in the PluginContext:
[source,typescript]
export async function handler(context: PluginContext) {
  const { kv } = context;
  // Set a value (with optional TTL in seconds)
  await kv.set('my-key', { data: 'value' }, { ttlSec: 3600 });
  // Get a value
  const value = await kv.get<{ data: string }>('my-key');
  // Atomic update with lock
  const updated = await kv.withLock('counter-lock', async () => {
    const count = (await kv.get<number>('counter')) ?? 0;
    const next = count + 1;
    await kv.set('counter', next);
    return next;
  }, { ttlSec: 10 });
  return { value, updated };
}=== Available Methods
get<T>(key: string): Promise<T | null>set(key: string, value: unknown, opts?: ttlSec?: number ): Promise<boolean>del(key: string): Promise<boolean>exists(key: string): Promise<boolean>listKeys(pattern?: string, batch?: number): Promise<string[]>clear(): Promise<number>withLock<T>(key: string, fn: () => Promise<T>, opts?: ttlSec?: number; onBusy?: 'throw' | 'skip' ): Promise<T | null>
Keys must match [A-Za-z0-9:_-]1,512 and are automatically namespaced per plugin.
== Migration from Legacy Patterns
=== Current Status
- ✅ Legacy plugins still work - No immediate action required
 - ⚠️ Deprecation warnings - Legacy plugins will show console warnings
 - 📅 Future removal - The legacy 
runPluginand two-parameterhandler(api, params)will be removed in a future major version - 🎯 Recommended action - Migrate to single-parameter 
PluginContexthandler for new plugins and KV access 
=== Migration Steps
If you have existing plugins using runPlugin() or the two-parameter handler, migration is simple:
Before (Legacy runPlugin - still works): [source,typescript]
import  runPlugin, PluginAPI  from "./lib/plugin";
async function myPlugin(api: PluginAPI, params: any): Promise<any>
    // Your plugin logic
    return result;
runPlugin(myPlugin); // ⚠️ Shows deprecation warningIntermediate (Legacy two-parameter - still works, no KV): [source,typescript]
import  PluginAPI  from "@openzeppelin/relayer-sdk";
export async function handler(api: PluginAPI, params: any): Promise<any>
  // Same plugin logic - ⚠️ Deprecated, no KV access
  return result;After (Modern context - recommended, with KV): [source,typescript]
import  PluginContext  from "@openzeppelin/relayer-sdk";
export async function handler(context: PluginContext): Promise<any>
  const  api, params, kv  = context;
  // Same plugin logic plus KV access!
  return result;=== Step-by-Step Migration
- Remove the 
runPlugin()call at the bottom of your file - Rename your function to 
handler(or create a new handler export) - Export the 
handlerfunction usingexport async function handler - Add proper TypeScript types for better development experience
 - Test your plugin to ensure it works with the new pattern
 - Update your documentation to reflect the new pattern
 
=== Backwards Compatibility
The relayer will automatically detect which pattern your plugin uses:
- If handler accepts one parameter → modern context pattern (with KV)
 - If handler accepts two parameters → legacy pattern (no KV, with warning)
 - If 
runPlugin()was called → legacy pattern (no KV, with warning) - If neither → shows clear error message
 
This ensures a smooth transition period where both patterns work simultaneously.