Sponsoring Transactions
This page shows the full client flow. It uses @mysten/sui v2. Every step is required.
Required scope: the API key needs the Sponsor scope. See API Key Scopes.
1. Read the tank config
const API = 'https://testnet-api.inodra.com/v1'
const headers = { 'x-api-key': process.env.INODRA_API_KEY!, 'Content-Type': 'application/json' }
const { data: config } = await fetch(`${API}/gas/config?tank=production`, { headers }).then((r) =>
r.json()
)Response:
{
"data": {
"tank": "production",
"network": "testnet",
"sponsorAddress": "0x7a…c3",
"gasPrice": "1000",
"chainIdentifier": "69WiPg3DAQiwdxfncX6wYQ2siKwAe6L9BZthQea3JNMD",
"epoch": "812",
"maxGasBudgetMist": "50000000",
"sponsorshipAvailable": true
}
}Every Fuel Station response wraps its payload in data. When you omit tank, the config route reads the tank named default. The response is cacheable for 30 seconds. gasPrice is the reference gas price for the current epoch. epoch changes about once a day. Re-read the config if a submit fails with 410 TRANSACTION_EXPIRED.
If sponsorshipAvailable is false, Inodra has paused sponsorship. Do not submit. Show the user a retry message.
2. Build the transaction
import { SuiGrpcClient } from '@mysten/sui/grpc'
import { Transaction } from '@mysten/sui/transactions'
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
const transport = new GrpcWebFetchTransport({
baseUrl: 'https://testnet-grpc.inodra.com',
meta: { 'x-api-key': process.env.INODRA_API_KEY! }
})
const client = new SuiGrpcClient({ network: 'testnet', transport })
const tx = new Transaction()
tx.setSender(userAddress)
tx.setGasOwner(config.sponsorAddress)
tx.setGasPayment([])
tx.setGasPrice(BigInt(config.gasPrice))
tx.setGasBudget(BigInt(config.maxGasBudgetMist))
tx.setExpiration({
ValidDuring: {
minEpoch: config.epoch,
maxEpoch: String(BigInt(config.epoch) + 1n),
minTimestamp: null,
maxTimestamp: null,
chain: config.chainIdentifier,
nonce: crypto.getRandomValues(new Uint32Array(1))[0]
}
})
tx.moveCall({ target: '0xPKG::game::play', arguments: [tx.pure.u64(1)] })
const bytes = await tx.build({ client })Each setter matters:
| Setter | Why |
|---|---|
setSender | The user. Never a tank address. |
setGasOwner | Must equal config.sponsorAddress, or submit returns 409 GAS_OWNER_MISMATCH. |
setGasPayment([]) | Must be empty. The tank pays from its address balance. Any coin ref is rejected. |
setGasPrice | Use config.gasPrice. Below the reference price is 422; above 5x is 403. |
setGasBudget | At most config.maxGasBudgetMist. Lower is fine and leaves more spendable balance. |
setExpiration | ValidDuring is required. maxEpoch at most epoch + 1. The nonce prevents replay. |
chain is the base58 genesis digest of the network, exactly as config.chainIdentifier returns it. Epochs are decimal strings. tx.build({ client }) resolves object references and returns the BCS bytes. Do not change the bytes after this step. The client only reads chain state here. See the gRPC Gateway for the server-side transport.
3. The user signs
With dapp-kit in a browser:
import { useSignTransaction } from '@mysten/dapp-kit'
const { mutateAsync: signTransaction } = useSignTransaction()
const { signature } = await signTransaction({ transaction: bytes })With a keypair on a server or in a test:
const { signature } = await keypair.signTransaction(bytes)The user signs the exact bytes from step 2. Inodra verifies the signature before it signs as sponsor.
4. Submit
import { toBase64 } from '@mysten/sui/utils'
const { data: result } = await fetch(`${API}/gas/submit`, {
method: 'POST',
headers,
body: JSON.stringify({
transactionBytes: toBase64(bytes),
userSignature: signature,
tank: 'production'
})
}).then((r) => r.json())The same call with curl:
curl -X POST https://testnet-api.inodra.com/v1/gas/submit \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"transactionBytes": "AAACAAg...",
"userSignature": "AJb2...",
"tank": "production"
}'Inodra finds the tank from the gas owner in the bytes. tank is an optional cross-check: when it names another tank, submit returns 409 GAS_OWNER_MISMATCH.
5. Read the result
A 200 means the transaction executed on chain. Check status for the on-chain outcome.
{
"data": {
"digest": "8Qk…Zp",
"tank": "production",
"status": "success",
"gasUsedMist": "1987600",
"feeMist": "1000000",
"effects": {
"executionError": null,
"epoch": "812",
"gas": {
"computationCostMist": "1000000",
"storageCostMist": "2964000",
"storageRebateMist": "1976400",
"nonRefundableStorageFeeMist": "19964"
}
}
}
}| Field | Meaning |
|---|---|
status | success or failure. A failure ran on chain and was aborted by Move or the VM. |
gasUsedMist | Net gas the tank paid, as a signed decimal string. Storage rebates can make it negative. |
feeMist | The Inodra fee for this sponsorship. Charged on success and on failure. |
executionError | The abort reason when status is failure. null on success. |
effects.gas | The four gas components from the transaction effects. |
All MIST values are decimal strings. Parse them with BigInt. For object changes and events, read the transaction by digest from the gRPC or GraphQL gateway.
The 202 pending case
Sometimes the node accepts the transaction and the connection drops before Inodra reads the result. Inodra then returns 202:
{ "data": { "status": "pending", "digest": "8Qk…Zp", "tank": "production" } }The transaction is on its way. Do not resubmit it. Poll the status:
curl https://testnet-api.inodra.com/v1/gas/transactions/8Qk…Zp \
-H "x-api-key: YOUR_API_KEY"{
"data": {
"digest": "8Qk…Zp",
"tank": "production",
"status": "executed",
"sender": "0x…",
"gasBudgetMist": "50000000",
"gasUsedMist": "1987600",
"feeMist": "1000000",
"error": null,
"createdAt": "2026-09-10T08:12:44.000Z",
"settledAt": "2026-09-10T08:12:51.000Z"
}
}| Status | Meaning |
|---|---|
reserved | Still pending. gasUsedMist is null. Poll again in a few seconds. |
executed | Landed on chain and succeeded. |
failed | Landed on chain and aborted. error holds the reason. The fee applied. |
expired | Never landed before maxEpoch. Nothing was charged. Build a new one. |
rejected | The node refused it before execution. Nothing was charged. |
Inodra settles pending sponsorships in the background within a few minutes.
Replay and nonce
- Inodra accepts each digest once. A second submit of the same bytes returns
410 REPLAYED_DIGEST. - A
503 CHAIN_UNAVAILABLEcan also use up the digest. Build a new transaction with a new nonce before you retry. - The
nonceinValidDuringmakes two otherwise identical transactions differ. Always set a fresh random nonce. maxEpochbounds how long the bytes stay valid. Set it toepoch + 1. Larger values are rejected.- If the epoch rolls over between build and submit, you get
410 TRANSACTION_EXPIRED. Re-read the config and rebuild.
Handling errors
Every error has the shape { "error": "...", "code": "...", "message": "..." }. Branch on code. See Errors for the full table. The ones you will hit most:
| Code | What to do |
|---|---|
INSUFFICIENT_TANK_FUNDS | Top up the tank. Deposits are credited within a minute. |
TRANSACTION_EXPIRED | Re-read the config, rebuild, sign again. |
DAILY_CAP_EXCEEDED | Wait for the daily reset or raise the cap in the dashboard. |
SENDER_NOT_ALLOWED | The sender is not in allowedSenders. Fix the rule or the sender. |
TARGET_NOT_ALLOWED | The Move call is not in allowedTargets. |