DEV Community

Cover image for DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another User's Transaction
Uroy Nwankwo
Uroy Nwankwo

Posted on

DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another User's Transaction

DUST sponsorship on Midnight lets a backend wallet pay transaction fees for users who do not have DUST yet. Every transaction on Midnight consumes DUST, a shielded, non-transferable capacity resource generated by holding NIGHT tokens. That design keeps fees predictable and privacy intact, but it creates onboarding friction: a brand-new wallet holds zero DUST. Without DUST, it cannot submit a single transaction, including the first one a user might want to make in your DApp.

DUST cannot be sent from one wallet to another. You cannot airdrop it, and there is no transferDUST() call.
The only way to cover fees for a wallet that has no DUST is through sponsorship. In this flow, a backend wallet with healthy DUST reserves pays the fees on behalf of the user without changing who signed the transaction or who owns its outputs.

Target audience: Developers building DApps on the Midnight network.

Prerequisites:

  • Familiarity with TypeScript
  • Basic understanding of the Midnight wallet SDK
  • A Midnight wallet setup with access to a proof server

Related reading: DUST architecture · Wallet SDK · Getting started

What you'll have by the end: A working model for DUST sponsorship, including the two-phase balancing flow, an Express sponsor service, the ownPublicKey() behavior in sponsored transactions, and the DUST monitoring rules needed to keep the sponsor wallet funded.


What DUST actually is

Before writing a single line of code, it helps to be precise about what you are working with.

DUST is not a token. It does not appear on a block explorer as a transferable asset. Think of it as a capacity resource, like bandwidth, generated continuously from your NIGHT holdings. The Midnight docs describe the analogy well: NIGHT is the solar panel, DUST is the electricity it produces.

A few key parameters on the Preview Testnet set the shape of that lifecycle:

Parameter Value What it means
night_dust_ratio 5,000,000,000 5 DUST generated per NIGHT held
generation_decay_rate 8,267 ~1 week to decay from max to zero
dust_grace_period 3 hours Final window after DUST hits zero

The DUST lifecycle moves through four phases:

  1. Generating: DUST accumulates toward a cap proportional to your NIGHT balance.
  2. Constant: DUST sits at its maximum while NIGHT remains unspent.
  3. Decaying: once the backing NIGHT UTXO is spent, DUST decays to zero over roughly one week.
  4. Grace: a 3-hour grace period allows one final transaction even at zero.

This lifecycle is entirely shielded. DUST UTXOs use commitments and nullifiers, just like Zswap. Spending one consumes the old UTXO and creates a new one with the updated value minus the fee.


The sponsorship flow

The SDK splits transaction balancing into independent phases, controlled by the tokenKindsToBalance parameter on balanceUnboundTransaction. This is the mechanism sponsorship relies on.

The flow has three steps:

Sequence diagram for DUST sponsorship

Step 1: The user balances their own tokens without DUST

The user calls balanceUnboundTransaction and explicitly excludes 'dust' from the tokenKindsToBalance array. This tells the SDK to settle shielded and unshielded inputs/outputs but leave the DUST portion open for someone else to fill.

import { WalletFacade } from '@midnight-ntwrk/wallet-sdk';

// userWallet is a WalletFacade instance, already synced
const transaction = await userWallet.transact(contractCall);

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' is deliberately omitted
  }
);

// Sign and finalize the user's portion
const userSigned = await userWallet.signRecipe(
  userRecipe,
  (payload) => userKeystore.signData(payload)
);
const userFinalized = await userWallet.finalizeRecipe(userSigned);

// Send userFinalized to the sponsor service
const response = await fetch('https://your-sponsor-service/sponsor', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ userFinalized }),
});

const { txHash } = await response.json();
console.log(`Transaction confirmed: ${txHash}`);
Enter fullscreen mode Exit fullscreen mode

The tokenKindsToBalance parameter is the key piece here. By leaving 'dust' out, the user is saying: "I'm settling my own tokens, but I'm not covering the fee. The sponsor will." The finalized transaction is complete from the user's perspective but is not yet submittable.

Step 2: The sponsor adds DUST fees

The sponsor service receives userFinalized and calls balanceFinalizedTransaction with tokenKindsToBalance: ['dust']. This time only DUST is balanced. The sponsor is not touching the user's shielded or unshielded tokens.

// sponsorWallet is a WalletFacade instance with healthy DUST reserves
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized,
  {
    shieldedSecretKeys: sponsorShieldedKeys,
    dustSecretKey: sponsorDustKey,
  },
  {
    ttl: new Date(Date.now() + 30 * 60 * 1000),
    tokenKindsToBalance: ['dust'], // only DUST
  }
);

const sponsorSigned = await sponsorWallet.signRecipe(
  sponsorRecipe,
  (payload) => sponsorKeystore.signData(payload)
);
const sponsorFinalized = await sponsorWallet.finalizeRecipe(sponsorSigned);
Enter fullscreen mode Exit fullscreen mode

Step 3: The sponsor submits

const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);
console.log(`Submitted: ${txHash}`);
Enter fullscreen mode Exit fullscreen mode

The transaction is now fully balanced. The user's tokens are accounted for, the sponsor's DUST covers the fee, and the combined transaction hits the network.


Full sponsor service implementation

Here is a complete Express-based sponsor service you can adapt for production.

// sponsor-service.ts
import express from 'express';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk';
import type { FinalizedTransaction } from '@midnight-ntwrk/wallet-api';

const app = express();
app.use(express.json({ limit: '1mb' }));

// Sponsor wallet state

let sponsorWallet: WalletFacade;
let sponsorShieldedKeys: { secretKey: Uint8Array };
let sponsorDustKey: { secretKey: Uint8Array };
let sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> };

async function initSponsorWallet(): Promise<void> {
  const seedPhrase = process.env.SPONSOR_SEED_PHRASE;
  if (!seedPhrase) throw new Error('SPONSOR_SEED_PHRASE is required');

  // Initialize the wallet facade with Preview Testnet config
  sponsorWallet = await WalletFacade.create({
    seedPhrase,
    networkId: process.env.NETWORK_ID ?? '0',
    rpcUrl: process.env.RPC_URL ?? 'https://rpc.midnight.network',
    indexerUrl: process.env.INDEXER_URL ?? 'https://indexer.midnight.network',
    proofServerUrl: process.env.PROOF_SERVER_URL ?? 'http://localhost:6300',
  });

  // Wait until the wallet has synced to the chain tip
  await sponsorWallet.waitForSync();

  // Derive keys. These come from your wallet initialization flow.
  sponsorShieldedKeys = { secretKey: sponsorWallet.shieldedSecretKey };
  sponsorDustKey = { secretKey: sponsorWallet.dustSecretKey };
  sponsorKeystore = {
    signData: (payload) => sponsorWallet.signWithUnshieldedKey(payload),
  };

  const state = await sponsorWallet.getState();
  console.log(`Sponsor wallet ready. DUST balance: ${state.dust.available}`);
}

// DUST monitoring

const DUST_LOW_WATERMARK = 100n; // alert threshold in DUST units

async function checkDustLevel(): Promise<void> {
  const state = await sponsorWallet.getState();
  const available = state.dust.available;

  if (available < DUST_LOW_WATERMARK) {
    console.warn(
      `Sponsor DUST low: ${available}. ` +
      `Add NIGHT to the sponsor wallet to regenerate.`
    );
    // In production: trigger a PagerDuty alert, Slack message, etc.
  }
}

// Sponsorship endpoint

app.post('/sponsor', async (req, res) => {
  const { userFinalized } = req.body as { userFinalized: FinalizedTransaction };

  if (!userFinalized) {
    return res.status(400).json({ error: 'userFinalized is required' });
  }

  try {
    await checkDustLevel();

    // Balance only the DUST portion. The user already balanced everything else.
    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);

    // Submit the fully balanced transaction
    const txHash = await sponsorWallet.submitTransaction(sponsorFinalized);

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

// Health check 

app.get('/health', async (_req, res) => {
  const state = await sponsorWallet.getState();
  res.json({ dust: String(state.dust.available), synced: state.isSynced });
});

// Startup 

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

Environment variables:

export SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
export NETWORK_ID="0"                                 # Preview Testnet
export RPC_URL="https://rpc.midnight.network"
export INDEXER_URL="https://indexer.midnight.network"
export PROOF_SERVER_URL="http://localhost:6300"
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install @midnight-ntwrk/wallet-sdk @midnight-ntwrk/wallet-api express
npm install --save-dev @types/express tsx typescript
Enter fullscreen mode Exit fullscreen mode

Run the service:

npx tsx src/sponsor-service.ts
Enter fullscreen mode Exit fullscreen mode

ownPublicKey() in sponsored transactions

One question comes up consistently when developers first work with sponsored transactions: whose key does ownPublicKey() return when the sponsor wallet is doing the balancing?

The answer is the prover's key, always. This is by design.

ownPublicKey() reflects whoever generated the ZK proof for the transaction, not whoever paid the DUST fee. In a standard sponsored flow, the user proves their own transaction before sending it to the sponsor, so ownPublicKey() returns the user's coinPublicKey. The sponsor's identity never leaks into the transaction's cryptographic identity.

This matters for three reasons:

  • Address derivation: outputs from the transaction are owned by the prover's address, not the sponsor's.
  • UTXO ownership: the wallet that can later spend those outputs is the prover's wallet.
  • Balance queries: if you are tracking state after the transaction, query the prover's address.

Some advanced architectures use an explicit key override pattern, where a separate prover wallet generates ZK proofs and the sponsor handles only fee payment and submission. In that configuration, the override is active at balance time:

// With key override active on the sponsor wallet:
sponsorWallet.ownPublicKey();
// → returns overrideKeys.coinPublicKey (the prover's key)
// → NOT the sponsor wallet's own coinPublicKey
Enter fullscreen mode Exit fullscreen mode

The key override is useful when you have a dedicated proof-generation service that is separate from your fee-paying backend. The prover wallet handles cryptographic identity and proof generation; the sponsor wallet handles DUST and submission. ownPublicKey() always reflects the prover, regardless of which wallet calls it.


DUST regeneration vs depletion

Understanding the regeneration curve is essential for sizing your sponsor wallet correctly. Once you know the math, operating a sponsor service becomes straightforward.

The regeneration curve

Dust regeneration curve on Midnight Network

While NIGHT remains unspent, DUST generates toward its cap and stays there. The moment a backing NIGHT UTXO is spent, for example, if the sponsor wallet moves NIGHT to another address, the associated DUST begins decaying. The decay takes roughly one week.

Sizing your sponsor wallet

Variable Value (Preview Testnet)
DUST generated per NIGHT 5 DUST per generation cycle
Typical fee per transaction ~0.001–0.01 DUST
Decay time (max to zero) ~1 week
Grace period at zero 3 hours

As a rule of thumb: a wallet holding 100 NIGHT can sponsor tens of thousands of transactions before you need to think about replenishment. In practice, the constraint is more likely to be DUST decay from NIGHT movement than raw transaction volume.

Keeping the sponsor wallet healthy

Two practices keep a production sponsor service running smoothly:

Do not move NIGHT unless you need to. Every time you spend a backing NIGHT UTXO, even to consolidate, the associated DUST starts decaying. If you need to move NIGHT, do it during low-traffic periods and ensure the new NIGHT UTXO has time to build up DUST before the old one's decay expires.

Monitor DUST levels actively. The health endpoint in the service above returns the current DUST balance. Hook it up to whatever alerting you use, such as Datadog, PagerDuty, or a simple cron + Slack webhook, and set a low-watermark alert well before you hit the decay phase.

// Simple cron-style monitoring
setInterval(async () => {
  const state = await sponsorWallet.getState();
  const dustAvailable = state.dust.available;

  if (dustAvailable < DUST_LOW_WATERMARK) {
    await sendAlert(`Sponsor DUST at ${dustAvailable}. Replenish NIGHT.`);
  }
}, 5 * 60 * 1000); // check every 5 minutes
Enter fullscreen mode Exit fullscreen mode

Common mistakes

Mistake 1: Including 'dust' in the user's tokenKindsToBalance

// WRONG: fails if the user has no DUST
const recipe = await userWallet.balanceUnboundTransaction(
  transaction,
  keys,
  { tokenKindsToBalance: ['shielded', 'unshielded', 'dust'] }
  //                                                  ^^^^^ user has no DUST
);
Enter fullscreen mode Exit fullscreen mode

The fix: always omit 'dust' from the user's step and let the sponsor add it separately.

Mistake 2: The sponsor re-balances token types the user already settled

// WRONG: causes double-spend errors
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized,
  keys,
  { tokenKindsToBalance: ['dust', 'shielded', 'unshielded'] }
  //                              ^^^^^^^^^^^^^^^^^^^^^^^^^ already done
);
Enter fullscreen mode Exit fullscreen mode

The fix: the sponsor's tokenKindsToBalance should be ['dust'] only.

Mistake 3: No TTL set

// WRONG: transaction may expire before submission
const recipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized,
  keys,
  { tokenKindsToBalance: ['dust'] }
  // missing ttl
);
Enter fullscreen mode Exit fullscreen mode

The fix: always pass a TTL of 15–30 minutes for both the user step and the sponsor step. Without it, network congestion or a slow proof server can leave you with an expired transaction and a consumed DUST UTXO.

Mistake 4: Treating DUST as a transferable token

There is no transferDUST() call. DUST is generated from NIGHT and consumed as a resource. It exists only within the shielded context of the wallet that holds the corresponding NIGHT. The sponsorship flow described above is the only way to cover fees for another wallet.


Summary

Concept Key point
DUST Shielded capacity resource, not a token; generated from NIGHT holdings
Cold-start problem New wallets have zero DUST and cannot submit transactions
balanceUnboundTransaction User balances ['shielded', 'unshielded'] and omits 'dust'
balanceFinalizedTransaction Sponsor adds ['dust'] only
tokenKindsToBalance Controls which token types each party settles
ownPublicKey() Always returns the prover's key, not the sponsor's
DUST regeneration Grows while NIGHT is held; decays ~1 week after NIGHT is spent
Grace period 3 hours after DUST hits zero
Production pattern Express sponsor service + DUST monitoring + TTL on every step

DUST sponsorship solves the onboarding problem: new users can transact from their first session while your backend absorbs the fee cost. Once you understand the two-phase balancing approach, the implementation is straightforward. The operational overhead stays low as long as you monitor your sponsor wallet's DUST levels.


Next steps

You now have the sponsorship pattern and service structure. From here:

Top comments (0)