Skip to content

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 ​

ts
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:

json
{
  "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 ​

ts
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:

SetterWhy
setSenderThe user. Never a tank address.
setGasOwnerMust 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.
setGasPriceUse config.gasPrice. Below the reference price is 422; above 5x is 403.
setGasBudgetAt most config.maxGasBudgetMist. Lower is fine and leaves more spendable balance.
setExpirationValidDuring 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:

ts
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:

ts
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 ​

ts
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:

bash
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.

json
{
  "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"
      }
    }
  }
}
FieldMeaning
statussuccess or failure. A failure ran on chain and was aborted by Move or the VM.
gasUsedMistNet gas the tank paid, as a signed decimal string. Storage rebates can make it negative.
feeMistThe Inodra fee for this sponsorship. Charged on success and on failure.
executionErrorThe abort reason when status is failure. null on success.
effects.gasThe 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:

json
{ "data": { "status": "pending", "digest": "8Qk…Zp", "tank": "production" } }

The transaction is on its way. Do not resubmit it. Poll the status:

bash
curl https://testnet-api.inodra.com/v1/gas/transactions/8Qk…Zp \
  -H "x-api-key: YOUR_API_KEY"
json
{
  "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"
  }
}
StatusMeaning
reservedStill pending. gasUsedMist is null. Poll again in a few seconds.
executedLanded on chain and succeeded.
failedLanded on chain and aborted. error holds the reason. The fee applied.
expiredNever landed before maxEpoch. Nothing was charged. Build a new one.
rejectedThe 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_UNAVAILABLE can also use up the digest. Build a new transaction with a new nonce before you retry.
  • The nonce in ValidDuring makes two otherwise identical transactions differ. Always set a fresh random nonce.
  • maxEpoch bounds how long the bytes stay valid. Set it to epoch + 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:

CodeWhat to do
INSUFFICIENT_TANK_FUNDSTop up the tank. Deposits are credited within a minute.
TRANSACTION_EXPIREDRe-read the config, rebuild, sign again.
DAILY_CAP_EXCEEDEDWait for the daily reset or raise the cap in the dashboard.
SENDER_NOT_ALLOWEDThe sender is not in allowedSenders. Fix the rule or the sender.
TARGET_NOT_ALLOWEDThe Move call is not in allowedTargets.

The full-stack Sui data layer.