npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sui-gas-station

v0.1.6

Published

Self-hosted TypeScript gas sponsorship library for Sui

Readme

sui-gas-station

CI npm version

Self-hosted gas sponsorship library for Sui. Enable gasless transactions in your dApp — users transact without owning SUI.

Zero runtime dependencies. One peer dependency: @mysten/sui.

What Is Gas Sponsorship?

On Sui, every transaction costs gas — a small fee paid in SUI. Gas sponsorship lets one address (the sponsor) pay gas on behalf of another (the sender). The sender interacts with your dApp without needing to own SUI.

This is a two-party model:

  • Sponsor (your server) — Holds a funded wallet. Attaches gas to transactions and signs as the payer.
  • Sender (your user) — Builds the transaction operations (Move calls, transfers, etc.) and signs as the actor.

Both signatures are submitted together. Sui verifies both — the sender authorized the operation, the sponsor authorized paying for it.

Common use cases:

  • Onboarding new users who don't have SUI yet
  • Free-to-play games where the developer covers gas
  • Enterprise apps where the company sponsors employee transactions
  • Any dApp that wants zero-friction UX

sui-gas-station runs on your server — you control the wallet, the policies, and the costs. No third-party API, no per-transaction fees to a vendor.

Installation

npm install sui-gas-station @mysten/sui

@mysten/sui is a peer dependency — you bring your own version (^1.45.0).

Setup: Fund Your Sponsor Wallet

Before using the library, your sponsor wallet needs SUI. The library will split it into a pool of gas coins automatically.

1. Get your sponsor address

import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";

const keypair = Ed25519Keypair.fromSecretKey(process.env.SPONSOR_KEY!);
console.log("Sponsor address:", keypair.toSuiAddress());

2. Fund the address

Testnet / Devnet — free faucet:

# Using the Sui CLI
sui client faucet --address <YOUR_SPONSOR_ADDRESS> --url https://faucet.devnet.sui.io/v2/gas

# Or use the web faucet: https://docs.sui.io/guides/developer/getting-started/get-coins

Mainnet — transfer SUI from an exchange or another wallet to your sponsor address.

3. Verify the balance

# Using the Sui CLI
sui client gas --address <YOUR_SPONSOR_ADDRESS>

Minimum recommended: 10 SUI. The library splits this into 20 gas coins of 0.5 SUI each (configurable). Each coin handles one sponsored transaction at a time — 20 coins means up to 20 concurrent sponsorships.

4. Initialize the pool

const sponsor = new GasSponsor({ client, signer: keypair });
await sponsor.initialize(); // Fetches coins, splits to pool size, caches gas price

initialize() is idempotent — if the address already has properly-split coins (e.g., from a previous run), it reuses them.

Quick Start

import { SuiClient } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { GasSponsor } from "sui-gas-station";

const client = new SuiClient({ url: "https://fullnode.mainnet.sui.io" });
const keypair = Ed25519Keypair.fromSecretKey(process.env.SPONSOR_KEY!);

// 1. Initialize (splits coins into a pool)
const sponsor = new GasSponsor({ client, signer: keypair });
await sponsor.initialize();

// 2. Sponsor a transaction
const result = await sponsor.sponsorTransaction({
  sender: "0xUSER_ADDRESS",
  transactionKindBytes: kindBytes, // see "Client-Side Integration" below
});

// 3. Client executes with dual signatures
const response = await client.executeTransactionBlock({
  transactionBlock: result.transactionBytes,
  signature: [senderSignature, result.sponsorSignature],
  options: { showEffects: true },
});

// 4. Report execution (recycles the gas coin)
sponsor.reportExecution(result.reservation, response.effects!);

What are transactionKindBytes? The sender builds their Move calls without gas data using tx.build({ onlyTransactionKind: true }). This produces just the operations — "what I want to do" without "how I'm paying for it." Your gas station attaches the gas coin, sets the budget, and signs as sponsor. See Client-Side Integration for the full sender flow.

How It Works

Sui supports sponsored transactions where one address (the sponsor) pays gas on behalf of another (the sender). This requires:

  1. The sender builds a transaction kind (the operations, without gas data)
  2. The sponsor attaches gas data (coin, budget, price) and signs
  3. The sender signs the full transaction
  4. Both signatures are submitted together

sui-gas-station handles step 2 internally — managing a pool of pre-split gas coins, caching gas prices, handling epoch boundaries, and signing as sponsor.

Client (sender)                    Your Server (sponsor)
─────────────────                  ─────────────────────
Build tx kind bytes ──────────────→ sponsorTransaction()
                                     ├─ Reserve gas coin
                                     ├─ Attach gas data
                                     ├─ Build full tx
                                     └─ Sign as sponsor
                    ←────────────── { txBytes, sponsorSig }
Sign as sender
Execute with [senderSig, sponsorSig]
                    ──────────────→ reportExecution(effects)
                                     └─ Recycle gas coin

Server Example (Hono)

A complete gas station server in ~30 lines:

import { Hono } from "hono";
import { SuiClient } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { GasSponsor, GasStationError } from "sui-gas-station";

const client = new SuiClient({ url: "https://fullnode.testnet.sui.io" });
const keypair = Ed25519Keypair.fromSecretKey(process.env.SPONSOR_KEY!);

const sponsor = new GasSponsor({
  client,
  signer: keypair,
  policy: { maxBudgetPerTx: 50_000_000n },
});
await sponsor.initialize();

const app = new Hono();

app.post("/sponsor", async (c) => {
  const { sender, transactionKindBytes } = await c.req.json();
  try {
    const result = await sponsor.sponsorTransaction({
      sender,
      transactionKindBytes,
    });
    return c.json({
      transactionBytes: result.transactionBytes,
      sponsorSignature: result.sponsorSignature,
      reservation: result.reservation,
    });
  } catch (err) {
    if (err instanceof GasStationError) {
      return c.json({ error: err.code, message: err.message }, 503);
    }
    throw err;
  }
});

app.post("/report", async (c) => {
  const { reservation, effects } = await c.req.json();
  sponsor.reportExecution(reservation, effects);
  return c.json({ ok: true });
});

app.get("/stats", (c) => c.json(sponsor.getStats()));

export default app; // Bun: bun run server.ts | Node: serve with @hono/node-server

Production note: Add authentication middleware to /sponsor and /report before deploying — unauthenticated endpoints allow pool exhaustion via fabricated requests.

Client-Side Integration

The sender (client) builds transaction kind bytes — the operations without any gas data:

import { Transaction } from "@mysten/sui/transactions";
import { fromBase64, toBase64 } from "@mysten/sui/utils";

const tx = new Transaction();
tx.moveCall({ target: "0xpkg::module::function", arguments: [...] });

// Build kind bytes (no gas data)
const kindBytes = await tx.build({ onlyTransactionKind: true });

// Send to your gas station (base64-encode the bytes for JSON transport)
const res = await fetch("https://your-gas-station.com/sponsor", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sender: myAddress, transactionKindBytes: toBase64(kindBytes) }),
});
const { transactionBytes, sponsorSignature, reservation } = await res.json();

// Sign the full transaction as sender
const { signature } = await keypair.signTransaction(fromBase64(transactionBytes));

// Submit with both signatures
const response = await client.executeTransactionBlock({
  transactionBlock: transactionBytes,
  signature: [signature, sponsorSignature],
  options: { showEffects: true },
});

// Report back so the gas coin gets recycled — MUST be called even if the transaction failed,
// because the gas coin's ObjectRef changes regardless of transaction success/failure.
await fetch("https://your-gas-station.com/report", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ reservation, effects: response.effects }),
});

Note: If the sender's kind bytes reference tx.gas (e.g., splitCoins(tx.gas, ...)), the sponsor must set allowGasCoinUsage: true in the policy — see Security.

API Reference

new GasSponsor(options)

| Option | Type | Default | Description | | ---------------------- | ----------------- | -------------- | -------------------------------------- | | client | SuiClient | required | Sui JSON-RPC client | | signer | Signer | required | Sponsor keypair (must own SUI) | | policy | SponsorPolicy | — | Default policy for all requests | | targetPoolSize | number | 20 | Number of gas coins to maintain | | targetCoinBalance | bigint | 500_000_000n | Target balance per coin (0.5 SUI) | | minCoinBalance | bigint | 50_000_000n | Remove coins below this (0.05 SUI) | | reservationTimeoutMs | number | 30_000 | Auto-release reserved coins after this | | epochBoundaryWindow | number | 1_000 | Pause near epoch boundaries (ms) | | onPoolDepleted | (stats) => void | — | Callback when pool has no coins left |

sponsor.initialize(): Promise<void>

Fetches existing coins, splits them to target pool size, and caches the current gas price. Must be called before sponsorTransaction().

sponsor.sponsorTransaction(options): Promise<SponsoredTransaction>

| Option | Type | Required | Description | | ---------------------- | ---------------------- | -------- | ------------------------------------------- | | sender | string | yes | Sender's Sui address | | transactionKindBytes | string \| Uint8Array | yes | Transaction kind (base64 or bytes) | | gasBudget | bigint | no | Explicit budget (auto-estimated if omitted) | | policy | SponsorPolicy | no | Override default policy for this request |

Returns:

{
  transactionBytes: string; // Base64 — full tx ready for sender to sign
  sponsorSignature: string; // Base64 — sponsor's signature
  gasBudget: bigint; // Actual budget (may differ if auto-estimated)
  gasPrice: bigint; // Current reference gas price
  reservation: GasCoinReservation; // Pass to reportExecution()
}

sponsor.reportExecution(reservation, effects): void

Must be called after every sponsored transaction executes, even if the transaction failed on-chain. The gas coin's version/digest change regardless of transaction success — skipping this call leaves a stale ObjectRef in the pool.

Pass response.effects from executeTransactionBlock({ options: { showEffects: true } }).

sponsor.getStats(): PoolStats

Returns current pool statistics for monitoring:

{
  totalCoins: number;
  availableCoins: number;
  reservedCoins: number;
  totalBalance: bigint;
  sponsorAddress: string;
  currentEpoch: string;
  gasPrice: bigint;
}

sponsor.replenish(): Promise<void>

Re-fetches and splits coins to refill the pool. Call from onPoolDepleted or on a schedule.

sponsor.close(): Promise<void>

Merges remaining pool coins back into one. Call on graceful shutdown.

Policy Enforcement

Control who can use your gas station and how much they can spend:

const sponsor = new GasSponsor({
  client,
  signer: keypair,
  policy: {
    maxBudgetPerTx: 50_000_000n, // Cap at 0.05 SUI per tx
    blockedAddresses: ["0x..."], // Deny specific addresses
    allowedMoveTargets: [
      "0xpkg::module::function", // Only sponsor these Move calls
    ],
    allowGasCoinUsage: false, // Default — blocks gas coin drain attacks
    customValidator: async (sender, kindBytes) => {
      // Your logic: rate limits, auth checks, allowlists, etc.
      return isAllowed(sender);
    },
  },
});

Override the default policy per-request:

await sponsor.sponsorTransaction({
  sender,
  transactionKindBytes: kindBytes,
  policy: { maxBudgetPerTx: 100_000_000n }, // Higher limit for this request
});

Error Handling

All errors are GasStationError instances with typed codes:

import { GasStationError } from "sui-gas-station";

try {
  await sponsor.sponsorTransaction({ ... });
} catch (err) {
  if (err instanceof GasStationError) {
    switch (err.code) {
      case "POOL_EXHAUSTED":      // No coins available — retry later
      case "POOL_NOT_INITIALIZED": // Forgot to call initialize()
      case "POLICY_VIOLATION":     // Request rejected by policy
      case "BUILD_FAILED":         // Transaction build/dry-run failed
      case "SIGN_FAILED":          // Sponsor signing failed
      case "INVALID_EFFECTS":     // Bad effects passed to reportExecution()
    }
  }
}

Security: Gas Coin Drain Prevention

By default, the library rejects transaction kind bytes that reference the sponsor's gas coin in PTB commands (SplitCoins, TransferObjects, MergeCoins, MoveCall, MakeMoveVec). This prevents a class of drain attacks where a malicious sender crafts kind bytes like SplitCoins(GasCoin, [amount]) + TransferObjects to extract value from the sponsor's coin beyond gas fees.

If your use case intentionally splits or moves coins from the gas coin, opt in:

const sponsor = new GasSponsor({
  client,
  signer: keypair,
  policy: { allowGasCoinUsage: true },
});

Or per-request:

await sponsor.sponsorTransaction({
  sender,
  transactionKindBytes: kindBytes,
  policy: { allowGasCoinUsage: true },
});

Deployment

The gas station is a long-running server — it keeps the coin pool in memory for fast sponsorship. Serverless platforms (Lambda, Cloudflare Workers) aren't ideal because the pool reinitializes on every cold start.

Local Development

# With Bun (recommended — runs TypeScript directly)
SPONSOR_KEY=<base64-key> bun run server.ts

# With Node
SPONSOR_KEY=<base64-key> npx tsx server.ts

Production

Any platform that supports a persistent Node.js/Bun process works:

| Platform | Deploy command | Cost | | ---------------------------------------- | --------------------------------- | ------------------- | | Railway | Connect GitHub repo, auto-deploys | ~$5/mo | | Fly.io | fly launch && fly deploy | ~$5/mo | | Render | Connect GitHub repo, auto-deploys | Free tier available | | DigitalOcean | Droplet or App Platform | $6/mo | | Any VPS | git clone && bun run server.ts | Varies |

Environment Variables

| Variable | Description | | ------------- | --------------------------------------------------------- | | SPONSOR_KEY | Base64-encoded Ed25519 private key for the sponsor wallet |

Keep this key secure. It controls a funded wallet. Use your platform's secrets manager — never commit it to source control.

Monitoring

Use the /stats endpoint to monitor pool health:

curl https://your-gas-station.com/stats
{
  "totalCoins": 20,
  "availableCoins": 18,
  "reservedCoins": 2,
  "totalBalance": "10000000000",
  "sponsorAddress": "0x...",
  "currentEpoch": "425",
  "gasPrice": "750"
}

Set up the onPoolDepleted callback to alert you when the pool runs dry:

const sponsor = new GasSponsor({
  client,
  signer: keypair,
  onPoolDepleted: (stats) => {
    console.warn("Gas pool depleted!", stats);
    // Send alert to Slack, PagerDuty, etc.
  },
});

Architecture

The library has two internal components:

  • GasSponsor — Public API. Handles the sponsorship flow, gas price caching, epoch boundary detection.
  • CoinPool — Internal. Manages a pool of pre-split SUI coins. Handles reservations, timeouts, and effects-based coin recycling (no extra RPC calls).

Key design decisions:

  • Effects-based coin refresh — After execution, the gas coin's new version/digest comes from TransactionEffects, not an extra RPC call. Zero latency, no race conditions.
  • Epoch boundary handling — Gas price is cached with epoch-aware TTL. Near epoch boundaries, sponsorship pauses briefly to avoid stale gas prices.
  • Reservation timeouts — Coins auto-release after 30s if reportExecution() is never called (crashed clients).
  • Structural typing for effects — The library accepts any object matching the ExecutionEffects shape, compatible with the SDK's TransactionEffects type.

Examples

Runnable examples in the examples/ directory:

# Basic sponsorship flow (initialize → sponsor → execute → report)
SPONSOR_KEY=<base64-key> npx tsx examples/basic-sponsorship.ts

# Policy enforcement (budget caps, blocked addresses, custom validators)
SPONSOR_KEY=<base64-key> npx tsx examples/with-policy.ts

License

Apache-2.0