For game developers
Gas sponsoring
How rare.market pays the network fee for a new player's first essential actions on Sui, which transactions qualify, the limits, and a full example.
rare.market can pay the Sui network fee for a new player's first essential actions, so a wallet with no SUI can still claim a trophy or make a first swap. The player's wallet builds only the transaction kind (its commands). rare.market adds the gas, checks the transaction, signs as the gas owner, and submits it once the player has signed the same bytes.
What is sponsored
Sponsoring covers a short list of actions, judged from the transaction's commands. Each transaction must be exactly one of these categories.
Your game's own Move calls are not sponsored. A transaction that calls any package outside these categories is refused with not_sponsored. Check categories in GET /sponsor/status for what is switched on right now.
Only wallets holding 0.1 SUI or less are sponsored. Wallets with more pay their own fee.
Always refused
- Any use of the gas coin as an argument, including
SplitCoinsfrom the gas coin. - Withdrawals from any address balance but the sender's.
PublishandUpgrade.- Transfers to the sponsor, and recipients that are not plain addresses.
- Mixing categories in one transaction, and empty transactions.
The transaction must also pass two dry runs: it must succeed, cost at most 0.05 SUI net gas and 0.02 SUI net storage, and change no balance of the sponsor's other than the gas itself.
The flow
Build the transaction kind
Build your
TransactionwithonlyTransactionKind: trueand base64 the bytes. Never pay from the gas coin: the sponsor's coin pays the gas.POST /sponsor
Send
{ sender, kind, category }. rare.market checks the policy and limits, sets itself as gas owner, dry runs the transaction twice, signs, and returns the full transaction bytes with its signature.The player signs the same bytes
Check that the bytes carry your sender and your kind, then have the player's wallet sign them with
signTransaction(notsignAndExecuteTransaction).POST /sponsor/execute
Send
{ digest, signature }within 2 minutes. rare.market adds its signature and submits the transaction once.
POST /sponsor and POST /sponsor/execute send no CORS headers, so a web page can't call them from the browser. Call them from your game server or a native client, and have the browser only sign. GET /sponsor/status is part of the public API: any origin can read it, and it counts toward your rate limit like other reads.
Request a sponsorship
curl -X POST "https://rare.market/api/v1/sponsor" \
-H "content-type: application/json" \
-d '{"sender":"0x7a1f...c3d2","kind":"AAMBAQ...","category":"trophy"}'{
"data": {
"bytes": "AAADAQ...AAA=",
"sponsorSignature": "AHq3...Q==",
"digest": "8nTq...v4Yx",
"sponsor": "0x...",
"category": "trophy",
"gasBudget": "13875000",
"estimatedGasMist": "9700000",
"expiresAt": 1793041320000
}
}Long values are shortened here.
Sending the same request again while its sponsorship has more than 15 seconds left returns the same sponsorship.
Execute
curl -X POST "https://rare.market/api/v1/sponsor/execute" \
-H "content-type: application/json" \
-d '{"digest":"8nTq...v4Yx","signature":"AFb2...dw=="}'{
"data": {
"digest": "8nTq...v4Yx",
"ok": true,
"gasUsedMist": "9214880"
}
}ok is false, with an error, when the transaction ran on chain but failed. Its gas was still paid and counts toward the player's limits. A digest executes at most once.
Check eligibility
Pass address to see what this player can get right now.
curl "https://rare.market/api/v1/sponsor/status?address=0x7a1f...c3d2"Limits
Days are UTC days. A sponsorship counts from the moment rare.market signs it; one that expires without being sent stops counting.
Errors
Errors have the body { "error": { "code", "message" } }. The message is a plain sentence you can show the player.
{
"error": {
"code": "not_sponsored",
"message": "This wallet has enough SUI to pay its own fee"
}
}Treat every refusal as a normal outcome: fall back to the player paying their own fee.
Full example
A trophy claim, sponsored. This runs in Node 18 or later with @mysten/sui. A keypair stands in for the player so the example runs on its own; in your game, the player's wallet signs bytes with signTransaction (for example dapp-kit's useSignTransaction with transaction: bytes).
import { bcs } from "@mysten/sui/bcs";
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { fromBase64, normalizeSuiAddress, toBase64 } from "@mysten/sui/utils";
const API = "https://rare.market/api/v1";
const client = new SuiGrpcClient({ network: "mainnet", baseUrl: "https://fullnode.mainnet.sui.io:443" });
type Category = "trophy" | "trade" | "rescue" | "walrus";
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error?.message ?? `HTTP ${res.status}`), { code: json.error?.code, status: res.status });
return json.data as T;
}
/** The sponsored bytes must carry our sender, our kind, and the sponsor as gas owner. */
function checkSponsored(bytes: string, kind: string, sender: string, sponsor: string) {
const data = bcs.TransactionData.parse(fromBase64(bytes)).V1;
if (!data) throw new Error("unknown transaction version");
if (normalizeSuiAddress(data.sender) !== normalizeSuiAddress(sender)) throw new Error("sender changed");
if (normalizeSuiAddress(data.gasData.owner) !== normalizeSuiAddress(sponsor)) throw new Error("gas owner is not the sponsor");
if (toBase64(bcs.TransactionKind.serialize(data.kind).toBytes()) !== kind) throw new Error("transaction changed");
}
export async function runSponsored(tx: Transaction, sender: string, category: Category, sign: (bytes: Uint8Array) => Promise<string>) {
// 1. Kind bytes only. The sponsor's coin pays the gas, so never touch tx.gas.
tx.setSenderIfNotSet(sender);
const kind = toBase64(await tx.build({ client, onlyTransactionKind: true }));
// 2. Ask rare.market to cover the fee.
const s = await post<{ bytes: string; digest: string; sponsor: string; expiresAt: number }>("/sponsor", { sender, kind, category });
checkSponsored(s.bytes, kind, sender, s.sponsor);
// 3. The player signs exactly these bytes.
const signature = await sign(fromBase64(s.bytes));
// 4. rare.market adds its signature and submits.
const r = await post<{ digest: string; ok: boolean; error?: string; gasUsedMist: string }>("/sponsor/execute", { digest: s.digest, signature });
if (!r.ok) throw new Error(r.error ?? "transaction failed");
return r.digest;
}
// --- usage: claim a Rare trophy for a player with no SUI ---
const player = Ed25519Keypair.fromSecretKey(process.env.PLAYER_KEY!);
const sender = player.toSuiAddress();
const tx = new Transaction();
// ...add the trophy claim calls here (see /docs/games/trophies)...
try {
const digest = await runSponsored(tx, sender, "trophy", async (bytes) => (await player.signTransaction(bytes)).signature);
console.log("claimed, fee covered by rare.market:", digest);
} catch (e) {
const err = e as Error & { code?: string };
// not_sponsored, limit_reached, sponsor_unavailable, ...: let the player pay their own fee instead.
console.log("not sponsored:", err.code, err.message);
}If /sponsor/execute answers sponsorship_expired, ask /sponsor for a new sponsorship and have the player sign again.
