DEV Community

Akanji Rahman
Akanji Rahman

Posted on • Edited on

DUST sponsorship: How one wallet pays fees for another user's transaction

DUST sponsorship: How one wallet pays fees for another user's transaction

Every transaction on Midnight consumes DUST. New users start with a DUST balance of zero, which creates a problem; how can they submit transactions if they cannot pay fees, especially the transactions needed to acquire NIGHT tokens in the first place?

DUST sponsorship solves this. A sponsor (typically a backend wallet) that already holds NIGHT tokens and has accumulated DUST covers the fee portion of another user's transaction. The user's keys sign the user's assets; the sponsor's keys sign only the DUST portion. The two halves are joined into one valid, on-chain transaction.

This tutorial walks through the complete sponsorship flow:

  • How tokenKindsToBalance splits fee responsibility between the user and the sponsor
  • A full sponsor service implementation
  • How DUST regenerates and depletes

Before you begin

Make sure you are comfortable with the following:

Understanding DUST

A common mistake is treating DUST as a token. You cannot buy it on an exchange or send it between addresses. DUST is a shielded resource that:

  • Accumulates continuously while you hold NIGHT tokens
  • Gets consumed each time you submit a transaction that requires fees
  • Decays over approximately one week if you move your NIGHT tokens away
  • Cannot be transferred directly from one address to another. The sponsorship flow described in this tutorial is the only mechanism to pay fees on behalf of another wallet

The key parameters on the Preview network are:

Parameter Value What it means
night_dust_ratio 5,000,000,000 Maximum of 5 DUST per NIGHT held
generation_decay_rate 8,267 Approximately one week to generate from zero to maximum, or decay from maximum to zero
dust_grace_period 3 hours Timestamp tolerance window that allows back-dated DUST spends within 3 hours of the current block time

Each wallet has three distinct addresses: a shielded address, an unshielded address, and a DUST address. The DUST address starts empty. Transactions cannot proceed until DUST is present or a sponsor covers the fees.

How sponsorship works

Sponsorship splits the transaction balancing process into two scoped operations:

  1. The user balances their own shielded and unshielded token inputs, explicitly excluding DUST.
  2. The sponsor takes the user's partially balanced transaction and adds DUST fees from their own DUST balance.

The sponsor then submits the fully balanced transaction to the network. The tokenKindsToBalance parameter makes this split possible: it controls which resource type each party handles.

The user balances without DUST

Call balanceUnboundTransaction on the user's wallet, excluding 'dust' from tokenKindsToBalance.

// user-side.ts

import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { UnboundTransaction } from "@midnight-ntwrk/ledger-v8";

async function prepareUserTransaction(
  userWallet: WalletFacade,
  transaction: UnboundTransaction, // built from your contract call or transfer intent
  userShieldedKeys: Uint8Array[],
  userDustKey: Uint8Array,
  userKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> }
) {
  const userRecipe = await userWallet.balanceUnboundTransaction(
    transaction,
    {
      shieldedSecretKeys: userShieldedKeys,
      dustSecretKey: userDustKey,
    },
    {
      ttl: new Date(Date.now() + 30 * 60 * 1000), // 30-minute TTL
      tokenKindsToBalance: ["shielded", "unshielded"], // 'dust' deliberately omitted
    }
  );

  const userSigned = await userWallet.signRecipe(userRecipe, (payload) =>
    userKeystore.signData(payload)
  );

  return await userWallet.finalizeRecipe(userSigned);
}
Enter fullscreen mode Exit fullscreen mode

The SDK processes shielded and unshielded inputs but leaves the fee slot unpopulated (this is intentional). signRecipe and finalizeRecipe seal the user's cryptographic commitments into a serialisable object. Once sealed, neither the sponsor nor any other party can alter the asset portion without invalidating those commitments. The 30-minute TTL applies to the entire flow: if the sponsor does not submit within that window, the network rejects the transaction. Pass the return value of finalizeRecipe to your sponsor service as the userFinalized payload.

Note: You still need to pass dustSecretKey even though 'dust' is excluded from tokenKindsToBalance. The SDK requires it for internal bookkeeping and fee estimation. Excluding 'dust' from tokenKindsToBalance prevents it from attempting to draw from an empty balance.

The sponsor adds DUST fees

Call balanceFinalizedTransaction on the sponsor's wallet, restricting tokenKindsToBalance to ['dust'].

// sponsor-side.ts

import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { FinalizedTransaction } from "@midnight-ntwrk/ledger-v8";

async function sponsorTransaction(
  sponsorWallet: WalletFacade,
  sponsorShieldedKeys: Uint8Array[],
  sponsorDustKey: Uint8Array,
  sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> },
  userFinalized: FinalizedTransaction // received from the user via API
) {
  const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
    userFinalized,
    {
      shieldedSecretKeys: sponsorShieldedKeys,
      dustSecretKey: sponsorDustKey,
    },
    {
      ttl: new Date(Date.now() + 30 * 60 * 1000),
      tokenKindsToBalance: ["dust"], // sponsor handles DUST only
    }
  );

  const sponsorSigned = await sponsorWallet.signRecipe(
    sponsorRecipe,
    (payload) => sponsorKeystore.signData(payload)
  );

  const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
  return await sponsorWallet.submitTransaction(sponsorFinalized);
}
Enter fullscreen mode Exit fullscreen mode

balanceFinalizedTransaction receives a transaction that already carries the user's zero-knowledge proofs and asset commitments; it adds DUST inputs on top without touching the existing proof data. Restricting tokenKindsToBalance to ['dust'] is not optional; including 'shielded' or 'unshielded' would attempt to re-balance inputs the user already committed, causing a double-spending error. The signRecipefinalizeRecipesubmitTransaction chain seals the sponsor's DUST contribution and broadcasts the transaction as a single atomic operation. The network sees one transaction, not two.

How sponsorship preserves proof identity

A common concern is whether the sponsor's key ends up attached to the user's transaction. It does not.

Each party generates zero-knowledge proofs only for their own part of the transaction. The user's proofs are generated locally, using the user's own keys, before userFinalized leaves the client. By the time the sponsor receives it, those proofs are cryptographically sealed: signRecipe and finalizeRecipe lock the user's asset commitments so that no third party can alter or re-sign them without invalidating the entire proof. The sponsor generates a separate proof covering only the DUST spend, signed with the sponsor's own key.

If your Compact smart contract calls ownPublicKey(); a circuit witness function that returns the public key of whoever is running the proof locally, it will always resolve to the user's key, not the sponsor's. The sponsor never touches the user's circuit execution. The final on-chain transaction carries two distinct cryptographic domains: the user's asset proofs attributed to the user's key, and the sponsor's DUST proof attributed to the sponsor's key. Privacy is preserved for both parties.

Full sponsor service implementation

Wire the wallet lifecycle, the sponsorship endpoint, rate limiting, and DUST monitoring into a single Express application.

// sponsor-service.ts

import express from "express";
import * as bip39 from "bip39";
import * as ledger from "@midnight-ntwrk/ledger-v8";
import {
  WalletFacade,
  type DefaultConfiguration,
} from "@midnight-ntwrk/wallet-sdk-facade";
import { ShieldedWallet } from "@midnight-ntwrk/wallet-sdk-shielded";
import { DustWallet } from "@midnight-ntwrk/wallet-sdk-dust-wallet";
import {
  UnshieldedWallet,
  createKeystore,
  PublicKey,
  InMemoryTransactionHistoryStorage,
} from "@midnight-ntwrk/wallet-sdk-unshielded-wallet";
import {
  HDWallet,
  Roles,
  type AccountKey,
} from "@midnight-ntwrk/wallet-sdk-hd";
import { Buffer } from "buffer";

const app = express();
app.use(express.json({ limit: "2mb" }));

let sponsorWallet: WalletFacade | null = null;
let sponsorShieldedKeys: Uint8Array[] = [];
let sponsorDustKey: Uint8Array;
let sponsorKeystore: {
  signData: (payload: Uint8Array) => Promise<Uint8Array>;
};

function deriveRoleKey(
  accountKey: AccountKey,
  role: number,
  index = 0
): Buffer {
  const result = accountKey.selectRole(role).deriveKeyAt(index);
  if (result.type === "keyDerived") return Buffer.from(result.key);
  return deriveRoleKey(accountKey, role, index + 1);
}

async function initSponsorWallet() {
  const seedPhrase = process.env.SPONSOR_SEED_PHRASE;
  if (!seedPhrase) throw new Error("SPONSOR_SEED_PHRASE is required");

  const networkId = process.env.NETWORK_ID ?? "preview";
  const rpcUrl = process.env.RPC_URL ?? "wss://rpc.midnight.network";
  const indexerUrl =
    process.env.INDEXER_URL ?? "https://indexer.midnight.network";
  const proofServerUrl =
    process.env.PROOF_SERVER_URL ?? "http://localhost:6300";

  const seed = bip39.mnemonicToSeedSync(seedPhrase);
  const hdWallet = HDWallet.fromSeed(seed);
  if (hdWallet.type !== "seedOk")
    throw new Error("Failed to derive keys from seed phrase");

  const account = hdWallet.hdWallet.selectAccount(0);
  const shieldedSeed = deriveRoleKey(account, Roles.Zswap);
  const dustSeed = deriveRoleKey(account, Roles.Dust);
  const unshieldedKey = deriveRoleKey(account, Roles.NightExternal);
  hdWallet.hdWallet.clear();

  const shieldedKeys = ledger.ZswapSecretKeys.fromSeed(shieldedSeed);
  const dustKey = ledger.DustSecretKey.fromSeed(dustSeed);
  const unshieldedKeystore = createKeystore(unshieldedKey, networkId);

  const configuration: DefaultConfiguration = {
    networkId,
    costParameters: { feeBlocksMargin: 5 },
    relayURL: new URL(rpcUrl),
    provingServerUrl: new URL(proofServerUrl),
    indexerClientConnection: {
      indexerHttpUrl: `${indexerUrl}/api/v4/graphql`,
      indexerWsUrl: `${indexerUrl.replace(
        "https://",
        "wss://"
      )}/api/v4/graphql/ws`,
    },
    txHistoryStorage: new InMemoryTransactionHistoryStorage(),
  };

  sponsorWallet = await WalletFacade.init({
    configuration,
    shielded: (config) =>
      ShieldedWallet(config).startWithSecretKeys(shieldedKeys),
    unshielded: (config) =>
      UnshieldedWallet(config).startWithPublicKey(
        PublicKey.fromKeyStore(unshieldedKeystore)
      ),
    dust: (config) =>
      DustWallet(config).startWithSecretKey(
        dustKey,
        ledger.LedgerParameters.initialParameters().dust
      ),
  });

  await sponsorWallet.start(shieldedKeys, dustKey);

  sponsorShieldedKeys = shieldedKeys as unknown as Uint8Array[];
  sponsorDustKey = dustKey as unknown as Uint8Array;
  sponsorKeystore = unshieldedKeystore;

  const state = await sponsorWallet.waitForSyncedState();
  console.log("Sponsor wallet initialized");
  console.log("Sponsor DUST address:", state.dust.address);

  await checkDustLevel();
}

async function checkDustLevel() {
  if (!sponsorWallet) return;

  const state = await sponsorWallet.waitForSyncedState();
  const dustBalance = state.dust.totalCoins;
  const LOW_DUST_THRESHOLD = 100;

  if (dustBalance < LOW_DUST_THRESHOLD) {
    console.warn(
      `[WARN] Sponsor DUST balance is low: ${dustBalance}. Top up NIGHT holdings.`
    );
  } else {
    console.log(`Sponsor DUST balance: ${dustBalance}`);
  }
}

// Simple in-memory rate limiter; use Redis for production
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60_000;

function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const entry = requestCounts.get(ip);

  if (!entry || now > entry.resetAt) {
    requestCounts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
    return false;
  }

  entry.count += 1;
  return entry.count > RATE_LIMIT;
}

const processedTxIds = new Set<string>();

app.post("/sponsor", async (req, res) => {
  const clientIp = req.ip ?? "unknown";

  if (isRateLimited(clientIp)) {
    return res
      .status(429)
      .json({ success: false, error: "Rate limit exceeded" });
  }

  const { userFinalized, idempotencyKey } = req.body;

  if (!userFinalized) {
    return res
      .status(400)
      .json({ success: false, error: "Missing userFinalized" });
  }

  if (idempotencyKey && processedTxIds.has(idempotencyKey)) {
    return res
      .status(200)
      .json({ success: true, message: "Already processed" });
  }

  if (!sponsorWallet) {
    return res
      .status(503)
      .json({ success: false, error: "Sponsor wallet not ready" });
  }

  try {
    const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
      userFinalized,
      {
        shieldedSecretKeys: sponsorShieldedKeys,
        dustSecretKey: sponsorDustKey,
      },
      {
        ttl: new Date(Date.now() + 30 * 60 * 1000),
        tokenKindsToBalance: ["dust"],
      }
    );

    const sponsorSigned = await sponsorWallet.signRecipe(
      sponsorRecipe,
      (payload) => sponsorKeystore.signData(payload)
    );

    const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
    const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);

    if (idempotencyKey) processedTxIds.add(idempotencyKey);

    console.log(`Sponsored transaction submitted: ${txHash}`);
    await checkDustLevel();

    return res.json({ success: true, txHash });
  } catch (error) {
    console.error("Sponsorship failed:", error);
    return res.status(500).json({ success: false, error: String(error) });
  }
});

app.get("/health", async (_req, res) => {
  if (!sponsorWallet) {
    return res.status(503).json({ status: "not ready" });
  }

  const state = await sponsorWallet.waitForSyncedState();
  return res.json({ status: "ok", dustBalance: state.dust.totalCoins });
});

const PORT = parseInt(process.env.PORT ?? "3001", 10);

initSponsorWallet()
  .then(() => {
    app.listen(PORT, () =>
      console.log(`Sponsor service running on port ${PORT}`)
    );
  })
  .catch((err) => {
    console.error("Failed to initialize sponsor wallet:", err);
    process.exit(1);
  });
Enter fullscreen mode Exit fullscreen mode

HDWallet.fromSeed() derives three independent key sets from a single mnemonic using BIP-44 role paths. hdWallet.clear() is called immediately after to prevent the master seed from remaining in memory. WalletFacade.init() starts all three sub-wallets against the same network configuration, and start() triggers chain synchronisation. waitForSyncedState() then blocks until the wallet has caught up before the service begins accepting requests. The in-memory rate limiter and idempotency set are sufficient for a single-instance deployment. Replace both with Redis before running at scale. DUST balance is checked after every successful sponsorship so the service warns before the wallet runs dry rather than discovering the problem mid-request.

Environment variables

Set these environment variables before starting the service.

SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
NETWORK_ID="preview"
RPC_URL="wss://rpc.midnight.network"
INDEXER_URL="https://indexer.midnight.network"
PROOF_SERVER_URL="http://localhost:6300"
PORT="3001"
Enter fullscreen mode Exit fullscreen mode

NETWORK_ID must be the string identifier ('preview' or 'preprod'), not a number, because WalletFacade uses it for address encoding and network routing. RPC_URL requires a WebSocket URL (wss://) rather than HTTPS. The SDK maintains a persistent relay connection rather than making individual HTTP calls. PROOF_SERVER_URL points to a separately running proving server process; the service will not start if this process is not already available.

User client

This is the function your DApp calls to balance a transaction locally and hand the result to the sponsor service.

// user-client.ts

import { WalletFacade } from "@midnight-ntwrk/wallet-sdk-facade";
import type { UnboundTransaction } from "@midnight-ntwrk/ledger-v8";

const SPONSOR_URL = process.env.SPONSOR_URL ?? "http://localhost:3001";

export async function runSponsoredTransaction(
 userWallet: WalletFacade,
 transaction: UnboundTransaction, // built from your contract call or transfer intent
 userShieldedKeys: Uint8Array[],
 userDustKey: Uint8Array,
 userKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> }
): Promise<string> {
 const userRecipe = await userWallet.balanceUnboundTransaction(
 transaction,
 { shieldedSecretKeys: userShieldedKeys, dustSecretKey: userDustKey },
 {
 ttl: new Date(Date.now() + 30 * 60 * 1000),
 tokenKindsToBalance: ["shielded", "unshielded"],
 }
 );

 const userSigned = await userWallet.signRecipe(userRecipe, (payload) =>
 userKeystore.signData(payload)
 );

 const userFinalized = await userWallet.finalizeRecipe(userSigned);

 const idempotencyKey = crypto.randomUUID();

 const response = await fetch(`${SPONSOR_URL}/sponsor`, {
 method: "POST",
 headers: { "Content-Type": "application/json" },
 body: JSON.stringify({ userFinalized, idempotencyKey }),
 });

 const result = await response.json();

 if (!result.success) {
 throw new Error(`Sponsorship failed: ${result.error}`);
 }

 return result.txHash;
}
Enter fullscreen mode Exit fullscreen mode

The transaction parameter must be fully constructed before calling this function: it holds the contract call or transfer intent and is not modified here. The user's asset commitments are locked in during signRecipe. By the time userFinalized leaves the client, its asset portion is immutable and cannot be altered by the sponsor. A fresh idempotencyKey per transaction (not per session) prevents the sponsor from processing duplicate submissions if the network call is retried. The function throws a descriptive error if the sponsor rejects the transaction rather than returning silently.

DUST regeneration and depletion

To maintain a sponsor wallet efficiently, you need to understand the DUST lifecycle.

  • Generating: DUST accumulates towards a cap proportional to your NIGHT balance. The more NIGHT you hold, the higher the cap.
  • Constant: DUST sits at its maximum. Sponsorships draw from this pool while NIGHT holdings continuously replenish it.
  • Decaying: DUST begins to decay when NIGHT tokens leave the wallet. The decay period takes approximately one week to fall from maximum to zero.
  • Grace period: The protocol accepts DUST spends whose timestamp is within 3 hours of the current block time. This means you can submit transactions using the DUST value from up to 3 hours ago. Once that 3-hour window has elapsed with zero DUST, the wallet cannot transact until DUST regenerates.

Sizing your sponsor wallet

On the Preview network:

  • The maximum DUST balance is 5 DUST per NIGHT held
  • Transaction fees are dynamic and adjust based on network utilization and transaction complexity. There is no fixed per-transaction cost
  • Hold more NIGHT than your projected daily volume requires, since fee pressure increases as block utilization rises

For production deployments:

  1. Hold significantly more NIGHT than your projected daily transaction volume requires.
  2. Check state.dust.totalCoins via waitForSyncedState() after each sponsorship, as shown in the service above.
  3. Alert when DUST drops below a safe threshold. Do not wait for it to reach zero.
  4. Consider multiple sponsor wallets for high-volume DApps, distributing load across separate NIGHT holdings.

What happens when DUST runs out

If the sponsor's wallet is fully exhausted and the grace period has passed, balanceFinalizedTransaction throws an error when tokenKindsToBalance includes 'dust'. The transaction cannot be submitted.

Catch this error in your service, return a clear error response to the user, and alert your operations team to top up NIGHT holdings or switch to a backup sponsor wallet.

Common mistakes

Balancing all token types as the user

This is what happens when 'dust' is included in the user's tokenKindsToBalance.

// Wrong: fails immediately if the user has no DUST
await userWallet.balanceUnboundTransaction(transaction, keys, {
 tokenKindsToBalance: ["shielded", "unshielded", "dust"],
});
Enter fullscreen mode Exit fullscreen mode

The SDK attempts to draw from the user's DUST balance to cover fees, finds nothing, and throws before any network interaction occurs. The error message may not explicitly name DUST as the cause; tokenKindsToBalance should be the first thing you check when debugging a failed balance call. Remove 'dust' from this array and let the sponsor add it separately via balanceFinalizedTransaction.

Rebalancing token types the user already balanced

This is what happens when the sponsor re-includes token types the user already balanced.

// Wrong: causes double-spending errors
await sponsorWallet.balanceFinalizedTransaction(userFinalized, keys, {
 tokenKindsToBalance: ["dust", "shielded", "unshielded"],
});
Enter fullscreen mode Exit fullscreen mode

By the time the sponsor receives userFinalized, the shielded and unshielded inputs are cryptographically committed. Balancing them again creates conflicting spend proofs. The network rejects this as a double-spending attempt, which surfaces as an error during submission rather than during the balance step, making it harder to diagnose. Restrict the sponsor's call to ['dust'] only.

Setting a TTL that is too short

Both the user and sponsor calls must include a TTL long enough for the full round trip: user → sponsor → Midnight network → response. A minimum of 15 minutes is reasonable; 30 minutes is safer. If the round trip exceeds the TTL, the network rejects the transaction.

Assuming DUST is transferable

There is no way to transfer DUST directly between addresses, no market to buy it, and no shortcut. The sponsorship flow described in this tutorial is the only mechanism to pay fees on behalf of another wallet.

Summary

DUST sponsorship solves the cold-start problem without compromising Midnight's privacy model. The user's keys sign only the user's assets, and the sponsor's keys sign only the DUST fee. No private data crosses the boundary, and the transaction appears on-chain as a single, fully valid operation.

Next steps

Top comments (0)