Gas Waiver enables gas-free transactions on Stable. With Gas Waiver, applications cover gas fees on behalf of users, so users can interact with contracts without holding USDT0 for gas.
This guide covers integrating via the Waiver Server API. For the protocol-level specification (wrapper transaction mechanism, authorization, policy checks, execution semantics, security model), see Gas Waiver.
Prerequisites
- An API key for the Waiver Server, issued by the Stable team
- Target contract address must be registered in the waiver’s
AllowedTarget policy
Waiver Server
Base URLs:
- Mainnet: TBD
- Testnet:
https://waiver.testnet.stable.xyz
Authorization: Bearer <your-api-key>
Overview
The integration flow has three steps:
- Build an InnerTx: the user signs a transaction with
gasPrice = 0.
- Submit to Waiver Server: submit the signed transaction to the Waiver Server API.
- Handle the response: the waiver server wraps and broadcasts the transaction. Process the streamed results and surface the transaction hash to the user.
Step 1: create the user’s InnerTx
The user signs a standard transaction with gasPrice = 0. The to address and method selector must be permitted by the waiver’s AllowedTarget policy.
// config.ts
export const CONFIG = {
RPC_URL: "https://rpc.testnet.stable.xyz",
CHAIN_ID: 2201, // 988 for mainnet
WAIVER_SERVER: "https://waiver.testnet.stable.xyz",
USDT0_ADDRESS: "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9",
};
import { ethers } from "ethers";
import { CONFIG } from "./config";
const provider = new ethers.JsonRpcProvider(CONFIG.RPC_URL);
const usdt0 = new ethers.Contract(CONFIG.USDT0_ADDRESS, [
"function transfer(address to, uint256 amount) returns (bool)"
], provider);
const callData = usdt0.interface.encodeFunctionData("transfer", [
recipientAddress,
ethers.parseUnits("0.01", 18)
]);
const gasEstimate = await provider.estimateGas({
from: userWallet.address,
to: CONFIG.USDT0_ADDRESS,
data: callData,
});
const nonce = await provider.getTransactionCount(userWallet.address);
const innerTx = {
to: CONFIG.USDT0_ADDRESS,
data: callData,
value: 0,
gasPrice: 0,
gasLimit: gasEstimate,
nonce: nonce,
chainId: CONFIG.CHAIN_ID,
};
const signedInnerTx = await userWallet.signTransaction(innerTx);
gasPrice must be 0. If it is non-zero, the waiver server rejects the transaction.
Step 2: submit to the Waiver Server
import { CONFIG } from "./config";
const API_KEY = process.env.WAIVER_API_KEY;
const response = await fetch(`${CONFIG.WAIVER_SERVER}/v1/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}`,
},
body: JSON.stringify({
transactions: [signedInnerTx],
}),
});
Batch submissions
You can submit multiple signed transactions in a single request:
body: JSON.stringify({
transactions: [signedTx1, signedTx2, signedTx3],
})
Each result line includes an index field corresponding to the transaction’s position in the array.
Step 3: handle the response
The response is streamed as NDJSON (newline-delimited JSON). Each line corresponds to one submitted transaction.
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).trim().split("\n");
for (const line of lines) {
const result = JSON.parse(line);
if (result.success) {
console.log(`tx ${result.index} confirmed: ${result.txHash}`);
} else {
console.error(`tx ${result.index} failed: ${result.error.message}`);
}
}
}
Success response:
{"index": 0, "id": "abc123", "success": true, "txHash": "0x..."}
Failure response:
{"index": 1, "id": "def456", "success": false, "error": {"code": "VALIDATION_FAILED", "message": "invalid signature"}}
Error codes
| Code | Description |
|---|
PARSE_ERROR | Failed to parse transaction |
INVALID_REQUEST | Malformed request body |
BATCH_SIZE_EXCEEDED | Batch size exceeds allowed maximum |
VALIDATION_FAILED | Transaction validation failed (e.g., invalid signature, disallowed target) |
BROADCAST_FAILED | Failed to broadcast to chain |
RATE_LIMITED | Rate limit exceeded |
QUEUE_FULL | Server queue at capacity |
TIMEOUT | Request timed out |
API reference
GET /v1/health
Health check endpoint. Authentication: none.
POST /v1/submit
Submit a batch of signed inner transactions. Authentication: required (Bearer).
Request body:
{
"transactions": ["0x<signedInnerTx1>", "0x<signedInnerTx2>"]
}
Response is streamed as NDJSON. Each line corresponds to a submitted transaction index.
GET /v1/submit
WebSocket interface for streaming submissions. Authentication: required (Bearer).
Key takeaways
- Gas Waiver is a server-side integration: your backend submits signed user transactions to the Waiver Server. Users never interact with the Waiver Server directly.
- The user always signs the InnerTx, preserving signature integrity. The waiver cannot modify the user’s transaction.
- The target contract must be on the waiver’s
AllowedTarget list.
See also: