DEV Community

Akanji Rahman
Akanji Rahman

Posted 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 signRecipe → finalizeRecipe → submitTransaction 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://
...

Top comments (0)