DEV Community

Wilson
Wilson

Posted on • Originally published at gist.github.com

DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another

DUST Sponsorship on Midnight: How One Wallet Pays Fees for Another

The Problem: Users Without DUST Can't Transact

Midnight's privacy model relies on DUST — a shielded network resource, not a token, that pays for transaction fees. Every transaction consumes DUST. But here's the catch: new users start with zero DUST, and generating it requires holding NIGHT tokens through a multi-day lifecycle.

This creates a cold-start problem. How does a brand-new user submit their first transaction if they don't have DUST to pay fees?

The answer is DUST sponsorship: one wallet (the sponsor) pays the DUST fees for another user's transaction. This tutorial shows you exactly how.


Understanding DUST: Not a Token, a Resource

Before diving into sponsorship, you need to understand what DUST actually is.

DUST Is a Shielded Capacity Resource

DUST is generated by holding NIGHT tokens. It is not an ERC-20 token, not a fungible asset you can send between addresses. It's a capacity resource — like bandwidth allocation — that exists within the shielded context of a wallet.

Key parameters on the Preview network:

Parameter Value Meaning
night_dust_ratio 5,000,000,000 5 DUST per NIGHT held
generation_decay_rate 8,267 ~1 week to decay from max to zero
dust_grace_period 3 hours Grace period after DUST hits zero

The DUST Lifecycle

DUST follows four phases:

  1. Generating — DUST accumulates while NIGHT is held, growing toward a maximum proportional to your NIGHT balance.
  2. Constant (Max) — DUST has reached its cap and stays constant.
  3. Decaying — After NIGHT is moved or spent, DUST decays over approximately one week.
  4. Zero — DUST is fully depleted. A 3-hour grace period allows one final transaction.

Three Wallet Addresses

On the Preview network, each wallet has three addresses:

┌─────────────────────────────────┐
│  Wallet                         │
│  ├── Shielded Address            │  ← Privacy transactions
│  ├── Unshielded Address         │  ← Public transactions
│  └── DUST Address                │  ← Fee capacity resource
└─────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

A new wallet has no DUST. Without sponsorship, it cannot submit any transaction that requires fees.


The Sponsorship Flow: Three Steps

DUST sponsorship uses a specific sequence of Midnight SDK calls. The core idea is to split transaction balancing into two phases: the user balances their own shielded/unshielded tokens, then the sponsor balances the DUST portion.

Step 1: User Creates and Balances Without DUST

The user creates their transaction and balances it, explicitly excluding DUST from the balancing scope:

// User creates the transaction
const transaction = await userWallet.transact()

// User balances only shielded and unshielded tokens
// The key parameter is tokenKindsToBalance — omit 'dust'
const userRecipe = await userWallet.balanceUnboundTransaction(
  transaction,
  {
    shieldedSecretKeys: userShieldedKeys,
    dustSecretKey: userDustKey,
  },
  {
    ttl: new Date(Date.now() + 30 * 60 * 1000), // 30 min TTL
    tokenKindsToBalance: ['shielded', 'unshielded'], // ← NO 'dust'
  }
);

// User signs and finalizes their part
const userSigned = await userWallet.signRecipe(
  userRecipe,
  (payload) => userKeystore.signData(payload)
);
const userFinalized = await userWallet.finalizeRecipe(userSigned);
Enter fullscreen mode Exit fullscreen mode

The tokenKindsToBalance parameter is the critical piece. By excluding 'dust', the user says "I'm not paying DUST fees — someone else will."

Step 2: Sponsor Adds DUST Fees

The sponsor takes the user's finalized transaction and adds DUST fees:

// Sponsor receives userFinalized from Step 1 (via API, message queue, etc.)
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized,
  {
    shieldedSecretKeys: sponsorShieldedKeys,
    dustSecretKey: sponsorDustKey,
  },
  {
    ttl: new Date(Date.now() + 30 * 60 * 1000),
    tokenKindsToBalance: ['dust'], // ← ONLY 'dust'
  }
);

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

Here, tokenKindsToBalance: ['dust'] means the sponsor is only paying for the DUST portion. They are not re-balancing shielded or unshielded tokens.

Step 3: Sponsor Submits

The sponsor submits the fully balanced transaction to the network:

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

The transaction is now fully balanced — the user's tokens are accounted for, and the sponsor's DUST covers the fees.


Sponsor Service Architecture

For production use, you need a sponsor service that receives user transactions, adds DUST fees, and submits them. Here's a complete architecture:

┌────────────┐         ┌──────────────────┐         ┌─────────────┐
│  User App  │ ──POST──→│  Sponsor Service │ ──TX───→│  Midnight   │
│            │         │                  │         │  Network    │
│  (no DUST) │←──200───│  (has DUST)      │         │             │
└────────────┘         └──────────────────┘         └─────────────┘
                              │
                              ├── balanceFinalizedTransaction
                              ├── signRecipe
                              ├── finalizeRecipe
                              └── submitTransaction
Enter fullscreen mode Exit fullscreen mode

The sponsor service holds a wallet with sufficient DUST and exposes an API for users to submit their partially-balanced transactions.

Sponsor Service Implementation

import express from 'express';
import { Wallet, Resource } from '@midnight-ntwrk/wallet-api';
import { CoinPublicKey, EncPublicKey } from '@midnight-ntwrk/types';

const app = express();
app.use(express.json());

let sponsorWallet: Wallet & Resource;
let sponsorKeystore: { signData: (payload: Uint8Array) => Promise<Uint8Array> };

// Initialize sponsor wallet at startup
async function initSponsorWallet() {
  // ... wallet initialization with NIGHT-holding seed phrase
  // Ensure wallet has DUST capacity
}

// POST /sponsor — receive user's finalized transaction and sponsor it
app.post('/sponsor', async (req, res) => {
  try {
    const { userFinalized } = req.body;

    // Step 2: Sponsor adds DUST fees
    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);

    // Step 3: Submit
    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) });
  }
});

app.listen(3001, () => console.log('Sponsor service running on port 3001'));
Enter fullscreen mode Exit fullscreen mode

Advanced: Key Override Pattern

In some architectures, the sponsor wallet handles both balanceTx() and submitTx(), while a separate prover wallet generates the zero-knowledge proofs. This is the key override pattern.

How Key Override Works

When using key override, the sponsor wallet's balanceTx() and submitTx() calls use an external prover wallet for proof generation, while the sponsor wallet still handles the actual fee payment and submission.

// The prover's wallet — generates ZK proofs
let externalProverWallet: (Wallet & Resource) | null = null;

// Override keys pointing to the prover
let overrideKeys: {
  coinPublicKey?: CoinPublicKey;
  encryptionPublicKey?: EncPublicKey;
} = {};

// When the sponsor calls ownPublicKey(), it returns the PROVER's key
// because the override is active:
// ownPublicKey() → overrideKeys.coinPublicKey ?? wallet.coinPublicKey
Enter fullscreen mode Exit fullscreen mode

Key Override in Practice

// Provider that routes proof generation to the external prover
const providerWithOverride = {
  ...baseProvider,
  async proveTransaction(tx: Transaction) {
    if (externalProverWallet) {
      return externalProverWallet.proveTransaction(tx);
    }
    return baseProvider.proveTransaction(tx);
  },
};

// The sponsor wallet uses this provider
// balanceTx() → calls providerWithOverride.proveTransaction()
//               which routes to the external prover wallet
// submitTx() → uses the sponsor wallet's own submission path
Enter fullscreen mode Exit fullscreen mode

Important: ownPublicKey() Behavior

When key override is active, ownPublicKey() returns the prover's coinPublicKey, not the sponsor's. This matters for:

  • Address derivation — the transaction appears to come from the prover's address
  • UTXO ownership — outputs are owned by the prover's keys
  • Balance queries — must query the prover's address, not the sponsor's
// Without override:
wallet.ownPublicKey() // → wallet's own coinPublicKey

// With override:
wallet.ownPublicKey() // → overrideKeys.coinPublicKey (the prover's key)
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

Mistake 1: Balancing All Token Kinds at Once

// WRONG — this 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

Fix: The user must exclude 'dust' from their balancing. The sponsor adds it separately.

Mistake 2: Re-balancing Token Types the User Already Balanced

// WRONG — re-balances shielded/unshielded that the user already handled
const sponsorRecipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized,
  keys,
  { tokenKindsToBalance: ['dust', 'shielded', 'unshielded'] }
  //                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unnecessary
);
Enter fullscreen mode Exit fullscreen mode

Fix: The sponsor should only balance 'dust'. Re-balancing other token types can cause double-spending errors.

Mistake 3: Ignoring TTL Expiry

// WRONG — no TTL or TTL too short
const recipe = await sponsorWallet.balanceFinalizedTransaction(
  userFinalized, keys, { tokenKindsToBalance: ['dust'] }
  // No TTL! Transaction might expire before submission.
);
Enter fullscreen mode Exit fullscreen mode

Fix: Always set a reasonable TTL (15–30 minutes) for both user and sponsor balancing steps.

Mistake 4: Assuming DUST Is a Transferable Token

DUST cannot be sent from one address to another like a regular token. It is generated from NIGHT holdings and consumed as a resource. There is no transferDUST() function. The only way to pay DUST for another user's transaction is through the sponsorship flow described above.


DUST Regeneration

When a sponsor's DUST is consumed, it regenerates over time based on their NIGHT holdings. Understanding the regeneration timeline helps size your sponsorship service:

DUST Level
    │
Max ┤████████████████████████████████
    │                                  ╲
    │                                    ╲
    │                                      ╲
    │                                        ╲
    │                                          ╲
  0 ┤──────────────────────────────────────────────╳
    │← Generation →← Constant →← Decay (≈1 week) →│Zero
    │                                                │← Grace (3h)→│
Enter fullscreen mode Exit fullscreen mode

Sizing Your Sponsor Wallet

  • Preview network: 5 DUST per NIGHT per generation cycle
  • Decay time: ~1 week from max to zero
  • Each transaction consumes a small amount of DUST (typically 0.001–0.01 DUST)
  • Rule of thumb: A wallet holding 100 NIGHT can sponsor approximately 50,000–500,000 transactions before needing regeneration

For a production sponsor service, hold enough NIGHT to generate DUST well above your projected transaction volume, and monitor DUST levels to replenish before hitting the decay phase.


Complete Working Example

See the companion files in src/:

  • sponsor-service.ts — Full sponsor service with Express API, wallet initialization, and sponsorship endpoint
  • user-client.ts — Client-side code that creates a transaction, balances without DUST, and sends it to the sponsor
  • sponsor-wallet.ts — Sponsor wallet setup with DUST monitoring and auto-replenishment

Quick Start

# Install dependencies
npm install @midnight-ntwrk/wallet-api @midnight-ntwrk/types express

# Set environment variables
export SPONSOR_SEED_PHRASE="your twelve word seed phrase here"
export NETWORK_ID="0"  # Preview network
export RPC_URL="https://rpc.midnight.network"
export INDEXER_URL="https://indexer.midnight.network"

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

Testing the Flow

// In a separate script or browser
import { createSponsorshipClient } from './user-client';

const client = createSponsorshipClient('http://localhost:3001');

// 1. User creates and balances their transaction (without DUST)
const userFinalized = await client.prepareTransaction();

// 2. Send to sponsor service for DUST sponsorship
const result = await client.sponsorTransaction(userFinalized);

console.log(`Transaction confirmed: ${result.txHash}`);
Enter fullscreen mode Exit fullscreen mode

Security Considerations

  1. Validate user transactions — Your sponsor service should verify that incoming transactions are legitimate before paying DUST fees. Implement rate limiting and transaction content validation.

  2. Monitor DUST levels — A sponsor wallet can run out of DUST. Implement monitoring and alerting to ensure you always have sufficient DUST capacity.

  3. Protect sponsor keys — The sponsor's seed phrase controls NIGHT holdings and DUST generation. Store it securely (environment variables, HSM, or secret management service).

  4. Set transaction TTLs — Always set reasonable TTLs (15–30 minutes) to prevent stale transactions from consuming sponsor DUST.

  5. Implement idempotency — Use transaction hashes to prevent duplicate sponsorship of the same transaction.


Summary

Concept Key Point
DUST Shielded resource, not a token; generated from NIGHT
Cold-start problem New users have no DUST to pay fees
balanceUnboundTransaction User balances shielded/unshielded only
balanceFinalizedTransaction Sponsor adds DUST fees
tokenKindsToBalance Controls which token types each party balances
Key override Prover wallet generates proofs; sponsor wallet pays and submits
ownPublicKey() Returns prover's key when override is active
DUST regeneration ~1 week decay; 3-hour grace period at zero
Production pattern Sponsor service with Express API, monitoring, rate limiting

DUST sponsorship solves the cold-start problem on Midnight. By splitting the balancing responsibility between user and sponsor, new wallets can transact immediately without needing their own NIGHT holdings or DUST generation capacity.

Top comments (0)