curl --request POST \
--url https://demo-api.mobula.io/api/2/bridge/execute \
--header 'Content-Type: application/json' \
--data '
{
"originChainId": "<string>",
"intentId": "<string>",
"authority": "<string>",
"calls": [
{
"to": "<string>",
"value": "<string>",
"data": "<string>"
}
],
"batchNonce": "<string>",
"deadline": "<string>",
"gasToken": "<string>",
"minBalance": "<string>",
"batchSignature": "<string>",
"executionKind": "sponsored7702",
"authorizationList": [
{
"chainId": 123,
"address": "<string>",
"nonce": 123,
"yParity": 123,
"r": "<string>",
"s": "<string>"
}
],
"signature": "<string>"
}
'import requests
url = "https://demo-api.mobula.io/api/2/bridge/execute"
payload = {
"originChainId": "<string>",
"intentId": "<string>",
"authority": "<string>",
"calls": [
{
"to": "<string>",
"value": "<string>",
"data": "<string>"
}
],
"batchNonce": "<string>",
"deadline": "<string>",
"gasToken": "<string>",
"minBalance": "<string>",
"batchSignature": "<string>",
"executionKind": "sponsored7702",
"authorizationList": [
{
"chainId": 123,
"address": "<string>",
"nonce": 123,
"yParity": 123,
"r": "<string>",
"s": "<string>"
}
],
"signature": "<string>"
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
originChainId: '<string>',
intentId: '<string>',
authority: '<string>',
calls: [{to: '<string>', value: '<string>', data: '<string>'}],
batchNonce: '<string>',
deadline: '<string>',
gasToken: '<string>',
minBalance: '<string>',
batchSignature: '<string>',
executionKind: 'sponsored7702',
authorizationList: [
{
chainId: 123,
address: '<string>',
nonce: 123,
yParity: 123,
r: '<string>',
s: '<string>'
}
],
signature: '<string>'
})
};
fetch('https://demo-api.mobula.io/api/2/bridge/execute', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://demo-api.mobula.io/api/2/bridge/execute",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'originChainId' => '<string>',
'intentId' => '<string>',
'authority' => '<string>',
'calls' => [
[
'to' => '<string>',
'value' => '<string>',
'data' => '<string>'
]
],
'batchNonce' => '<string>',
'deadline' => '<string>',
'gasToken' => '<string>',
'minBalance' => '<string>',
'batchSignature' => '<string>',
'executionKind' => 'sponsored7702',
'authorizationList' => [
[
'chainId' => 123,
'address' => '<string>',
'nonce' => 123,
'yParity' => 123,
'r' => '<string>',
's' => '<string>'
]
],
'signature' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://demo-api.mobula.io/api/2/bridge/execute"
payload := strings.NewReader("{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://demo-api.mobula.io/api/2/bridge/execute")
.header("Content-Type", "application/json")
.body("{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/bridge/execute")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"data": {
"intentId": "<string>",
"status": "pending",
"depositTxHash": "<string>",
"sponsoredGasLimit": "<string>",
"message": "<string>"
}
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}Bridge Execute (Gasless)
[Alpha Preview] Submit a user-signed EIP-7702 batch and let Mobula broadcast it and pay the origin gas — bridge from an EVM wallet holding zero native token.
curl --request POST \
--url https://demo-api.mobula.io/api/2/bridge/execute \
--header 'Content-Type: application/json' \
--data '
{
"originChainId": "<string>",
"intentId": "<string>",
"authority": "<string>",
"calls": [
{
"to": "<string>",
"value": "<string>",
"data": "<string>"
}
],
"batchNonce": "<string>",
"deadline": "<string>",
"gasToken": "<string>",
"minBalance": "<string>",
"batchSignature": "<string>",
"executionKind": "sponsored7702",
"authorizationList": [
{
"chainId": 123,
"address": "<string>",
"nonce": 123,
"yParity": 123,
"r": "<string>",
"s": "<string>"
}
],
"signature": "<string>"
}
'import requests
url = "https://demo-api.mobula.io/api/2/bridge/execute"
payload = {
"originChainId": "<string>",
"intentId": "<string>",
"authority": "<string>",
"calls": [
{
"to": "<string>",
"value": "<string>",
"data": "<string>"
}
],
"batchNonce": "<string>",
"deadline": "<string>",
"gasToken": "<string>",
"minBalance": "<string>",
"batchSignature": "<string>",
"executionKind": "sponsored7702",
"authorizationList": [
{
"chainId": 123,
"address": "<string>",
"nonce": 123,
"yParity": 123,
"r": "<string>",
"s": "<string>"
}
],
"signature": "<string>"
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
originChainId: '<string>',
intentId: '<string>',
authority: '<string>',
calls: [{to: '<string>', value: '<string>', data: '<string>'}],
batchNonce: '<string>',
deadline: '<string>',
gasToken: '<string>',
minBalance: '<string>',
batchSignature: '<string>',
executionKind: 'sponsored7702',
authorizationList: [
{
chainId: 123,
address: '<string>',
nonce: 123,
yParity: 123,
r: '<string>',
s: '<string>'
}
],
signature: '<string>'
})
};
fetch('https://demo-api.mobula.io/api/2/bridge/execute', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://demo-api.mobula.io/api/2/bridge/execute",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'originChainId' => '<string>',
'intentId' => '<string>',
'authority' => '<string>',
'calls' => [
[
'to' => '<string>',
'value' => '<string>',
'data' => '<string>'
]
],
'batchNonce' => '<string>',
'deadline' => '<string>',
'gasToken' => '<string>',
'minBalance' => '<string>',
'batchSignature' => '<string>',
'executionKind' => 'sponsored7702',
'authorizationList' => [
[
'chainId' => 123,
'address' => '<string>',
'nonce' => 123,
'yParity' => 123,
'r' => '<string>',
's' => '<string>'
]
],
'signature' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://demo-api.mobula.io/api/2/bridge/execute"
payload := strings.NewReader("{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://demo-api.mobula.io/api/2/bridge/execute")
.header("Content-Type", "application/json")
.body("{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://demo-api.mobula.io/api/2/bridge/execute")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"originChainId\": \"<string>\",\n \"intentId\": \"<string>\",\n \"authority\": \"<string>\",\n \"calls\": [\n {\n \"to\": \"<string>\",\n \"value\": \"<string>\",\n \"data\": \"<string>\"\n }\n ],\n \"batchNonce\": \"<string>\",\n \"deadline\": \"<string>\",\n \"gasToken\": \"<string>\",\n \"minBalance\": \"<string>\",\n \"batchSignature\": \"<string>\",\n \"executionKind\": \"sponsored7702\",\n \"authorizationList\": [\n {\n \"chainId\": 123,\n \"address\": \"<string>\",\n \"nonce\": 123,\n \"yParity\": 123,\n \"r\": \"<string>\",\n \"s\": \"<string>\"\n }\n ],\n \"signature\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"data": {
"intentId": "<string>",
"status": "pending",
"depositTxHash": "<string>",
"sponsoredGasLimit": "<string>",
"message": "<string>"
}
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}{
"error": "<string>",
"intentId": "<string>"
}POST /api/2/bridge/execute broadcasts an EVM origin deposit on the user’s
behalf and pays its gas. The user signs the batch off-chain; Mobula’s solver
sends it. A wallet with 0 ETH/BNB/POL can approve and bridge in a single
signature.
The origin gas is not free — it’s priced into the quote and deducted from
the amount delivered, which is why /execute only accepts intents quoted with
gasless=true.
How it works
Under the hood this is an EIP-7702 sponsored batch:- The user’s EOA is delegated to
MobulaBatchExecutor. The delegation rides along as an authorization tuple on the first sponsored transaction — the wallet never sends a delegation transaction of its own. - The user signs an EIP-712
Batch— the quote’sapprove+bridgeToken/swapAndBridgesteps, exactly the transactions they would otherwise broadcast themselves. - Mobula submits a type-4 transaction carrying that batch and pays the gas.
The calls execute from the user’s own EOA, so
msg.senderat the bridge is still the user, and the deposit is indistinguishable from a self-sent one (same refund path, same/statuslifecycle).
Authentication
Same as every bridge endpoint, and required — the call is refused with401
without it. Pass your API key as a query parameter on the POST URL
(POST /api/2/bridge/execute?apiKey=YOUR_API_KEY) or send it as
Authorization: Bearer <apiKey-or-short-lived-JWT>, exactly like /quote.
The request costs one credit on the resolved key.
Availability
| Requirement | Rule |
|---|---|
| Origin chain | EVM only, and only chains with a deployed executor (table below). Solana origins have their own gasless mechanism — pass feePayerAddress on /quote instead. |
| Route | Cross-chain only. Same-chain swaps have no bridge deposit to sponsor and are refused at the quote. |
| Origin token | ERC-20 only. A native (or wrapped-native) origin deposit carries native value, and a sponsored batch may not move value. |
| Quote | Must have been requested with gasless=true. A normally-quoted intent is refused — its output never paid for the gas. |
| Wallet | Signing the 7702 authorization requires a signer that can produce an authorization tuple (embedded/local accounts, e.g. viem’s signAuthorization, or Privy’s useSign7702Authorization). Most injected browser wallets cannot. Once the account is delegated, later deposits need only a plain signTypedData. |
sponsorGate.batchExecutor, and the authorization tuple must name whatever that
says. A client holding its own copy is a second source of truth for the address
its batch is signed against: when the executor is redeployed, the batch is built
for one delegate and submitted against an account still delegated to another,
which does not necessarily fail loudly. Since no intent can reach /execute
without a gasless=true quote first, reading it from the quote costs nothing.
It is currently the same address on every supported chain, but that is a fact
about today’s deployment, not a guarantee:
| Chain | MobulaBatchExecutor |
|---|---|
evm:8453 (Base) | 0x29440460fbdda286fe259b0b1cbfbc018d47dfda |
evm:56 (BSC) | 0x29440460fbdda286fe259b0b1cbfbc018d47dfda |
evm:42161 (Arbitrum) | 0x29440460fbdda286fe259b0b1cbfbc018d47dfda |
evm:137 (Polygon) | 0x29440460fbdda286fe259b0b1cbfbc018d47dfda |
evm:4663 (Robinhood Chain) | 0x29440460fbdda286fe259b0b1cbfbc018d47dfda |
The flow
GET /2/bridge/quote?...&gasless=true → intentId, steps, sponsorGate
sign authorization (EIP-7702 — first gasless deposit only)
sign Batch (EIP-712 — the approve + deposit calls)
POST /2/bridge/execute → depositTxHash
GET /2/bridge/status/{intentId}/wait → filled
Request body
| Field | Required | Notes |
|---|---|---|
originChainId | yes | The quote’s origin chain (evm:8453, …). |
intentId | yes | From the gasless=true quote. It is also the idempotency key. |
authority | yes | The user’s EOA: batch signer, authorization signer, and the account the calls run from. Must be the address the intent was quoted for. |
calls | yes | The quote’s steps mapped 1:1 to { to, value, data } — approve then bridgeToken/swapAndBridge. Max 4 calls. Every value must be "0". Don’t drop the approve: the batch must do exactly what the quote priced. |
batchNonce | yes | Decimal string. (keccak256(utf8Bytes(intentId)) & (2**192 - 1)) << 64 — this intent’s dedicated nonce lane (see Nonce lanes). |
deadline | yes | Unix seconds (string). After it, the batch signature is refused on-chain and by the API. ~5 minutes is a sensible TTL. |
gasToken | yes | The origin token being bridged. Must equal the token the intent was quoted for. |
minBalance | yes | Raw units the account must hold for the batch to run. Must be at least the quoted deposit amount. |
batchSignature | yes | EIP-712 signature over the Batch payload below, by authority. |
authorizationList | conditional | One EIP-7702 tuple on the account’s first sponsored deposit on that chain; [] once it’s already delegated to MobulaBatchExecutor. |
signature | no | The bridge-intent EIP-712 signature. Not needed on EVM origins — the quote already committed the intent — and accepted only for backward compatibility. |
executionKind | no | Reserved; only sponsored7702 exists today. |
Batch EIP-712 schema
Domain:
name: "MobulaBatchExecutor"
version: "1"
chainId: <numeric origin chain id> // 8453, 56, 42161, 137, 4663
verifyingContract: <the user's EOA> // the ACCOUNT, not the executor
Call:
to address
value uint256 // always 0
data bytes
Batch:
calls Call[] // the quote's steps, in order
nonce uint256 // batchNonce (the intent's lane)
deadline uint256 // unix seconds
gasToken address // the origin token you are bridging
minBalance uint256 // the deposit amount, in that token's raw units
gasToken and minBalance are a balance gate the executor checks before any
call runs: if the account does not hold minBalance of gasToken, the batch
reverts immediately. It exists because Mobula pays the gas whether a batch
succeeds or fails, so a deposit that was never going to move any tokens has to
fail in ~35k gas rather than burning the whole limit.
Set them to the origin token and the exact amount the quote priced —
/execute refuses a batch whose gasToken is not the quoted origin token, or
whose minBalance is below the quoted deposit.
verifyingContract is the user’s own account — that’s what stops a batch
signed for one delegated EOA from being replayed against another.
Authorization tuple
{ "chainId": 8453, "address": "0x7025…d736", "nonce": 42, "yParity": 0, "r": "0x…", "s": "0x…" }
addressmust be the chain’sMobulaBatchExecutor; anything else is refused.chainIdmust be the origin chain — the0“any chain” wildcard is refused.nonceis the authority’s current account nonce, notnonce + 1: the solver submits the transaction, not the user. With viem’ssignAuthorization, that means not passingexecutor: 'self'. Get this wrong and the chain silently drops the authorization, leaving the batch to callexecuteon an account with no code.
eth_getCode(authority): an empty result is a
plain EOA, and a delegated one is exactly 0xef0100 || <20-byte delegate>. Send
a tuple when that delegate isn’t MobulaBatchExecutor.
Nonce lanes
MobulaBatchExecutor uses a two-dimensional nonce — key << 64 | seq,
sequential within a lane, independent across lanes — and every intent gets its
own lane derived from its intentId. Two deposits signed before either mines
can’t collide, and a fresh lane always starts at sequence 0, so no client ever
reads the chain to pick a nonce. Re-signing the same intent reuses its lane,
which is what makes a retry mutually exclusive with the attempt it replaces.
Response
{
"data": {
"intentId": "a3b4ba1-e34523c-324",
"status": "pending",
"depositTxHash": "0x…",
"sponsoredGasLimit": "1150000",
"message": "Deposit broadcast; poll /bridge/status/:id for the fill"
}
}
depositTxHashis the sponsored transaction carrying the batch.sponsoredGasLimitis the gas units the send was capped at — exactly the units the quote charged for.statusis alwayspending: the deposit has been broadcast, not yet filled. Poll/status/{intentId}/waitas with any other deposit.
What the server checks
A sponsored batch spends Mobula’s gas, so/execute validates rather than
rebuilds it (rebuilding would change the calldata and void the user’s
signature):
- The intent’s prediction must say it was quoted gasless, and quoted for
this
authority. - Every
calls[].tomust be one of three addresses: the origin token the quote priced (to approve), the chain’sSwapBridgeHelper, orMobulaBridge. Anything else is out of scope. - No call may carry native
value. batchNoncemust be this intent’s lane;deadlinemust be in the future; at most 4 calls.batchSignaturemust recover toauthority, and every authorization tuple must be signed byauthorityand delegate toMobulaBatchExecutoron this chain.- The gas limit of the broadcast is the quote’s own units — never a client- or solver-supplied number.
Idempotency and retries
intentId is the idempotency key: the first /execute for an intent claims it
for 15 minutes, and a second returns 409. On a 504 the claim is
deliberately retained — the broadcast may still land, so poll
/status/{intentId} before doing anything else. Every other failure releases
the claim, so you can fix the request and retry the same intent.
When the batch reverts on-chain
A200 means the transaction was broadcast, not that it succeeded. A batch
can still revert once mined — a stale approval, a swap that moves past its own
limit, an out-of-gas. When it does, no tokens moved, so no deposit exists and no
bridge intent is ever created.
/status/{intentId} reports that case as failed rather than leaving it
pending:
{
"data": {
"id": "…",
"status": "failed",
"depositTxHash": "0x…",
"failureReason": "CallFailed(index=1)",
"message": "The sponsored deposit reverted on-chain — nothing left your wallet. Request a new quote."
}
}
Errors
| Status | Meaning | What to do |
|---|---|---|
400 | Batch or intent refused: not quoted as gasless, wrong authority, out-of-scope call, non-zero value, wrong nonce lane, expired deadline, bad bridge-intent signature. | Fix and re-quote; nothing was sent. |
401 | batchSignature doesn’t recover to authority. | Check the EIP-712 domain (verifyingContract = the user’s EOA) and the signer. |
409 | This intent was already submitted, or its batch already reverted on-chain. | Poll /status/{intentId}. If it reads failed, re-quote — do not re-execute. |
502 | The solver refused or failed to broadcast (bad authorization, send failure). | Nothing was sent; retry the same intent or re-quote. |
503 | Single-execution or quote verification couldn’t be guaranteed. | Retry shortly. |
504 | The solver didn’t answer within 30 s. | The deposit may still land — poll /status/{intentId} before retrying. |
{ "error": "...", "intentId": "..." }.
Example
Base → BSC, 100 USDC → USDT, from a wallet with no ETH. Two HTTP calls, two signatures (one of which disappears after the first ever gasless deposit).import { createPublicClient, createWalletClient, http, keccak256, toHex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const API = "https://api.mobula.io/api/2/bridge";
const KEY = "YOUR_API_KEY";
const EXECUTOR = "0x29440460fbdda286fe259b0b1cbfbc018d47dfda";
const account = privateKeyToAccount("0xYourPrivKey");
const client = createWalletClient({ account, chain: base, transport: http() });
const publicClient = createPublicClient({ chain: base, transport: http() });
// 1. Quote it as gasless. The origin gas is already deducted from estimatedAmountOut.
const params = new URLSearchParams({
originChainId: "evm:8453",
destinationChainId: "evm:56",
originToken: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", // USDC on Base
destinationToken: "0x55d398326f99059fF775485246999027B3197955", // USDT on BSC
amount: "100",
walletAddress: account.address,
senderAddress: account.address,
gasless: "true",
apiKey: KEY,
});
const { data: quote, error } = await fetch(`${API}/quote?${params}`).then((r) => r.json());
if (error) throw new Error(error);
// 2. Delegate the EOA, unless it already points at MobulaBatchExecutor.
const code = await publicClient.getCode({ address: account.address });
const delegate = code?.startsWith("0xef0100") ? `0x${code.slice(8, 48)}` : null;
const authorizationList =
delegate?.toLowerCase() === EXECUTOR.toLowerCase()
? []
: [
// No `executor: 'self'` — Mobula submits, so the tuple takes the account's CURRENT nonce.
await client.signAuthorization({ account, contractAddress: EXECUTOR }),
];
// 3. Sign the batch: the quote's steps, verbatim, in this intent's nonce lane.
const calls = quote.steps.map((s) => ({ to: s.tx.to, value: s.tx.value ?? "0", data: s.tx.data }));
const batchNonce = (BigInt(keccak256(toHex(quote.intentId))) & ((1n << 192n) - 1n)) << 64n;
const deadline = BigInt(Math.floor(Date.now() / 1000) + 300);
const batchSignature = await client.signTypedData({
account,
domain: {
name: "MobulaBatchExecutor",
version: "1",
chainId: base.id,
verifyingContract: account.address, // the ACCOUNT, not the executor
},
types: {
Call: [
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "data", type: "bytes" },
],
Batch: [
{ name: "calls", type: "Call[]" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
{ name: "gasToken", type: "address" },
{ name: "minBalance", type: "uint256" },
],
},
primaryType: "Batch",
message: { calls, nonce: batchNonce, deadline, gasToken, minBalance },
});
// 4. Hand it to Mobula, which broadcasts and pays the gas.
const res = await fetch(`${API}/execute?apiKey=${KEY}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
executionKind: "sponsored7702",
originChainId: "evm:8453",
intentId: quote.intentId,
authority: account.address,
calls,
batchNonce: batchNonce.toString(),
deadline: deadline.toString(),
batchSignature,
authorizationList,
}),
});
const executed = await res.json();
if (!res.ok) throw new Error(executed.error);
console.log("Sponsored deposit:", executed.data.depositTxHash);
// 5. Same status flow as any other deposit.
const status = await fetch(`${API}/status/${quote.intentId}/wait?apiKey=${KEY}`).then((r) => r.json());
console.log(status.data.status); // "filled"
Cost model
The user pays no origin gas, but the trade does. On agasless=true quote:
fees.originSponsorGasUsdis what the sponsored send costs, and it’s already deducted fromestimatedAmountOutand included intotalFeeUsd.- The signed
minAmountOutis derived from that same netted amount, so the floor the solver enforces matches the number the user was shown. - The units charged are the exact gas limit the broadcast gets — the batch can never burn more than the quote reserved.
"Amount does not cover the sponsored origin gas ($…)" rather than quoting a
payout it can’t honour.
See also
- Bridge Quote —
gasless=true, the typed data, and the deposit steps this endpoint batches. - Bridge Status — the lifecycle after the deposit is broadcast.
- Bridge Implementation guide — the full non-gasless flow across EVM, Solana, and HyperLiquid.
Body
Origin chain ID of the quote (e.g., "evm:8453"). Must be an EVM chain with a MobulaBatchExecutor.
The intentId returned by the gasless=true quote.
The user's EOA — signer of the batch, and the account the batched calls execute from. Must match the address the intent was quoted for.
The quote's steps, in order, as batch calls (approve → bridgeToken/swapAndBridge). Max 4, each with value "0".
Show child attributes
Show child attributes
MobulaBatchExecutor nonce, decimal string. Must equal this intent's lane: (keccak256(utf8Bytes(intentId)) & (2**192 - 1)) << 64.
Unix seconds after which the batch signature is refused. Must be in the future.
Token whose balance gates the batch on-chain, taken verbatim from the quote's sponsorGate.gasToken. Must be the token this intent was quoted for; anything else is refused.
Minimum gasToken balance the account must hold for the batch to run, from the quote's sponsorGate.minBalance. Must be at least the quoted deposit amount. The executor checks it before any call runs, so a batch that was never going to move tokens fails in ~35k gas instead of burning its whole limit.
65-byte EIP-712 signature by authority over the Batch(calls, nonce, deadline, gasToken, minBalance) payload (domain MobulaBatchExecutor v1, verifyingContract = authority).
Which bridge entrypoint the batch ends on, matching the quote's last step type.
bridgeToken, swapAndBridge Execution mechanism. Only sponsored7702 exists today; the field is optional and reserved.
sponsored7702 EIP-7702 authorization tuples. Exactly one on the account's first sponsored send (it delegates the EOA to MobulaBatchExecutor in the same transaction); empty afterwards.
Show child attributes
Show child attributes
Optional EIP-712 bridge-intent signature over the quote's typedData. Not needed on EVM origins — GET /2/bridge/quote commits the intent on its own call — and accepted only for backward compatibility.
Response
The sponsored deposit was broadcast.
Show child attributes
Show child attributes